Як працює useState у React?
useState - це хук React, який дозволяє функціональному компоненту зберігати локальний стан. Повертає поточне значення і функцію-сеттер, яка ставить перерендер у чергу.
Теорія
TL;DR
- Уяви дисплей торгового автомату: показує поточне число, ти натискаєш кнопку (сеттер) і нове значення ставиться в чергу для наступного циклу (рендер).
- Стан зберігається між рендерами. Звичайна змінна всередині компонента скидається при кожному виклику функції.
- Сеттер не оновлює стан одразу. Він ставить оновлення в чергу, і React об'єднує кілька викликів в один рендер.
useState- для локальних даних компонента. Для спільного стану між компонентами -useContext. Для складної логіки оновлень -useReducer.- Коли нове значення залежить від попереднього, завжди використовуй функціональну форму:
setState(prev => next).
Швидкий приклад
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0); // [поточне значення, сеттер]
return (
<div>
<p>Лічильник: {count}</p>
<button onClick={() => setCount(c => c + 1)}>
+ {/* функціональне оновлення читає останнє значення з черги */}
</button>
</div>
);
}useState(0) задає початкове значення 0. Клік по кнопці викликає сеттер з функцією, яка отримує останній стан і повертає наступне значення. React ставить рендер у чергу, і компонент покаже 1.
Головна відмінність
На відміну від звичайної змінної, стан зберігається між рендерами. Але сеттер не змінює значення під час поточного рендеру. Він ставить оновлення в чергу, і React застосовує всі накопичені оновлення разом перед наступним відображенням. Саме тому, якщо прочитати count одразу після setCount(count + 1), отримаєш старе значення. Нове значення з'явиться тільки в наступному рендері.
Коли використовувати
- Локальне перемикання стану (відкрити/закрити модалку, вибір вкладки) - один
useStateна булеве значення. - Контрольовані поля форми -
useStateна поле, або одинuseState({})для всієї форми. - Незалежні значення - окремі виклики
useState, а не один великий об'єкт. - Логіка оновлення з кількома підзначеннями або умовами -
useReducerбуде зрозумілішим. - Стан, потрібний кільком компонентам - підніми його вище або використовуй
useContextзuseStateна вищому рівні. - Нове значення залежить від поточного стану - завжди функціональна форма, щоб уникнути застарілих читань.
Як useState зберігає стан
React зберігає дані хуків кожного компонента у fiber node - внутрішньому об'єкті в дереві компонентів. Кожен виклик useState отримує один слот у зв'язному списку, прикріпленому до цього fiber. Перший useState - слот 0, другий - слот 1, і так далі.
Саме тому виклик хуків всередині умов ламає React. Якщо useState стоїть всередині if, порядок слотів змінюється між рендерами і React читає неправильний стан з неправильного слота. Правило "викликай хуки тільки на верхньому рівні" існує саме з цієї причини.
Коли ти викликаєш сеттер (всередині React він називається dispatchAction), React додає об'єкт оновлення до черги цього слота. При наступному рендері React проходить усі хуки по порядку, обробляє чергу оновлень кожного і обчислює новий стан. У React 18 оновлення всередині подій, setTimeout і Promise.then автоматично об'єднуються в один рендер. У React 17 так об'єднувалися тільки оновлення всередині обробників подій.
Лінива ініціалізація
Передай функцію в useState, коли обчислення початкового значення є дорогою операцією:
// compute() виконується на кожному рендері, результат ігнорується після mount
const [data, setData] = useState(compute());
// compute() виконується лише один раз, при першому рендері
const [data, setData] = useState(() => compute());Цей підхід називається lazy initialization (лінива ініціалізація). React викликає функцію рівно один раз і використовує повернуте значення як початковий стан. Підходить для читання з localStorage, парсингу URL або обробки великого набору початкових даних.
Типові помилки
Пряма мутація стану:
const [arr, setArr] = useState([1, 2]);
arr.push(3); // React бачить ту саму посилання, рендер не відбудеться
setArr(arr); // Та сама посилання, React пропускає оновлення
// Правильно:
setArr([...arr, 3]);
// або функціональна форма:
setArr(a => [...a, 3]);Читання стану одразу після виклику сеттера:
const handleClick = () => {
setCount(count + 1);
console.log(count); // Виведе старе значення. Оновлення ще в черзі.
};Застарілий closure при кількох викликах сеттера:
// Обидва виклики читають однаковий count з поточного рендеру
const handleClick = () => {
setCount(count + 1); // count=0, ставить: встановити в 1
setCount(count + 1); // count=0, те саме замикання, ставить: встановити в 1 знову
// Результат: 1, а не 2
};
// Правильно: функціональні оновлення правильно ланцюжаться
const handleClick = () => {
setCount(c => c + 1); // c=0, результат: 1
setCount(c => c + 1); // c=1, результат: 2
};Неповне копіювання вкладених об'єктів:
const [user, setUser] = useState({ name: 'Alex', settings: { theme: 'light' } });
// Неправильно: settings досі вказує на оригінальний об'єкт
user.settings.theme = 'dark';
setUser(user); // та сама посилання, React пропускає рендер
// Правильно: копіюй кожен рівень, який змінюєш
setUser(u => ({
...u,
settings: { ...u.settings, theme: 'dark' }
}));
// Ще краще: розбий на окремі стани, якщо значення незалежні
const [theme, setTheme] = useState('light');Виклик сеттера під час рендеру:
function Bad() {
const [x, setX] = useState(0);
setX(1); // Нескінченний цикл: сеттер викликає рендер, рендер викликає сеттер
// Правильно: перемісти в useEffect або обробник події
}Один патерн, який навіть досвідчені розробники часто пропускають: виклик setState всередині forEach по поточному стану. Кожна ітерація захоплює той самий застарілий масив, і виживає тільки останнє оновлення. Рішення - функціональна форма або побудова повного наступного стану до єдиного виклику сеттера.
Де зустрічається в реальному коді
- Патерн React TodoMVC -
useState([])для списку задач, окремийuseStateдля активного фільтра. - Форми у Next.js -
useState({})для контрольованих полів, валідація перед відправкою. - Перемикач теми -
useState('light')з синхронізацією вlocalStorageпри кожній зміні. - React DevTools - відстежує стан компонентів через кілька
useStateна компонент. - Клієнтська валідація у Remix -
useStateзберігає повідомлення про помилки до відправки форми.
Питання на співбесіді
Q: Що станеться, якщо викликати сеттер під час рендеру?
A: React кине помилку "Too many re-renders." Сеттер викликає рендер, рендер знову викликає сеттер - нескінченний цикл. Перемісти оновлення стану в обробник події або useEffect.
Q: Що таке lazy initialization і коли її використовувати?
A: Передача функції в useState замість значення. React викликає її один раз при монтуванні. Підходить, коли обчислення початкового значення є дорогим: читання localStorage, парсинг великого набору даних.
Q: Яка різниця між setState(value) і setState(fn)?
A: setState(value) захоплює значення з поточного замикання (closure). Якщо кілька сеттерів об'єднуються в batch, всі вони читають той самий застарілий стан. setState(fn) отримує найновіше значення з черги оновлень як аргумент, тому ланцюжкові оновлення працюють коректно.
Q: Як auto-batching у React 18 змінює поведінку useState?
A: До React 18 об'єднувалися тільки оновлення всередині синтетичних обробників подій. React 18 також об'єднує оновлення в setTimeout, Promise.then і нативних обробниках, зменшуючи кількість зайвих рендерів. Код, який розраховував на окремий рендер для кожного асинхронного виклику сеттера, може поводитися інакше.
Q: (Senior) Чому хуки завжди мають викликатися в однаковому порядку?
A: React відстежує кожен useState за позицією у зв'язному списку fiber. Якщо порядок змінюється між рендерами (хук всередині if), індекс збивається і React читає неправильний стан з неправильного слота. Лінтер для хуків виловлює це на рівні інструментів.
Q: (Senior) Якщо викликати setCount з тим самим значенням, що й поточний стан, чи відбудеться рендер?
A: Ні. React порівнює нове і поточне значення через Object.is. Якщо вони рівні, React пропускає рендер повністю. Це вбудована оптимізація, яку не потрібно додавати вручну.
Приклади
Базовий: показати і приховати модалку
import { useState } from 'react';
function Modal() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(o => !o)}>
{isOpen ? 'Закрити' : 'Відкрити'} модалку
</button>
{isOpen && <div className="modal">Вміст модалки</div>}
</div>
);
}isOpen починається як false. Кнопка перемикає його через функціональне оновлення. React перерендерює і або показує, або ховає div. Зовнішній стейт-менеджмент тут зайвий.
Середній рівень: контрольована форма входу
import { useState } from 'react';
function LoginForm() {
const [form, setForm] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const handleChange = (e) => {
const { name, value } = e.target;
setForm(f => ({ ...f, [name]: value })); // оновлюємо одне поле, решта без змін
};
const handleSubmit = (e) => {
e.preventDefault();
if (!form.email.includes('@')) {
setError('Введи коректний email');
return;
}
setError('');
// відправити form.email і form.password на API
};
return (
<form onSubmit={handleSubmit}>
<input name="email" value={form.email} onChange={handleChange} />
<input name="password" type="password" value={form.password} onChange={handleChange} />
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit">Увійти</button>
</form>
);
}Два окремих useState відповідають за різні речі: один для значень полів, інший для повідомлення про помилку. Функціональне оновлення в handleChange розпаковує попередню форму і замінює тільки змінене поле, що уникає пастки з неповним копіюванням вкладеного стану.
Просунутий рівень: застарілий closure і batching у React 18
import { useState } from 'react';
function BadCounter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // count=0, ставить: встановити в 1
setCount(count + 1); // count=0, те саме замикання, ставить: встановити в 1 знову
// Після кліку: count = 1, а не 2
};
return <button onClick={handleClick}>Погано: {count}</button>;
}
function GoodCounter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(c => c + 1); // c=0, результат: 1
setCount(c => c + 1); // c=1, результат: 2
// Після кліку: count = 2
};
return <button onClick={handleClick}>Добре: {count}</button>;
}BadCounter використовує count напряму в обох викликах. React 18 об'єднує їх в один рендер, але обидва замикання захоплюють однаковий count (нуль). Черга оновлень містить дві однакові інструкції: "встановити в 1." Результат: 1.
GoodCounter використовує функціональні оновлення. React передає в кожен callback найновіше значення з черги, тому вони ланцюжаться: 0 -> 1 -> 2. Це правильний патерн для будь-якого обробника, який викликає той самий сеттер більше одного разу.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.