Створіть власний компонент useState для іграшок, щоб зрозуміти сніппети, черги та ефекти
Дізнайтеся, як працюють хуки React, створивши невеликий useState, а потім використайте цю модель для пояснення черг оновлень, застарілих знімків, закриттів функцій, очищення ефектів, референсів та групування операцій.
Джерела: React Hooks Internally: How `useState`, `useEffect`& Other Hooks Actually Work
Один рядок коду хуків приховує досить велику кількість складних механізмів:
const [count, setCount] = useState(0);
React повинен вирішити, де зберігається count, як він існує після кожного виклику, який стан має на увазі певний сеттер, чому виклики хуків не можуть бути умовними, та як ефекти відстежують залежності. У цьому посібнику створюється невелика працездатна модель хуків, яку використовують для пояснення поведінки, що щодня спричиняє проблеми: застарілі значення після setState, функціональні оновлення, очищення ефектів та їх групування.
Стан не знаходиться всередині функції
Візьмемо найменшу можливу складову зі станом:
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
Хочеться думати, що count знаходиться всередині Counter, але React просто знову викликає цю функцію під час кожного оновлення:
Render 1
Counter()
count = 0
Render 2
Counter()
count = 1
Render 3
Counter()
count = 2
Локальні змінні зникають, коли функція повертається, тому React зберігає стан хуків поза компонентом, прив’язаним до його Fiber, і передає збережене значення під час кожного оновлення.
Fiber зберігає послідовність хуків
У спрощеній формі цей зв’язок виглядає так:
Component
│
▼
Fiber
│
▼
Hook data
│
┌──┼───────────────┐
▼ ▼ ▼
State Ref Effect
Fiber — це одиниця роботи React, створена як для компонентів, так і для елементів-хостів. Для функційного компонента вона містить дані хуків, концептуально у вигляді ланцюга:
Counter Fiber
│
▼
Hook #1
│
▼
Hook #2
│
▼
Hook #3
У реальній реалізації є багато більше полів, але цього достатньо, щоб зрозуміти основну поведінку.
Хуки підбираються за порядком виклику
Ось компонент із трьома хуками:
function Component() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Salim");
useEffect(() => {
console.log(count);
}, [count]);
return ...;
}
У React немає імен для цих хуків; він знає лише «перший», «другий» та «третій» і припускає, що вони надходять у такій послідовності під час кожного оновлення:
Fiber
│
├── Hook #1 → useState
│
├── Hook #2 → useState
│
└── Hook #3 → useEffect
Це припущення є основою всіх правил хуків.
Що йде не так із умовним хуком
Тепер розгляньмо хук, прихований за умовою:
function Component({ loggedIn }) {
if (loggedIn) {
useState("Salim"); // ❌
}
useState(0);
return ...;
}
Під час відображення, коли користувач увійшов, викликаються два хуки:
loggedIn = true
Hook #1 → name
Hook #2 → count
Якщо значення loggedIn згодом стане false, відбувається лише один виклик:
loggedIn = false
Hook #1 → count
Поле, яке раніше використовувалося для зберігання імені, тепер отримує виклик для підрахунку, тож стан поєднується з неправильним хуком:
Before:
1 → name
2 → count
After:
1 → count
Тому порядок хуків ніколи не повинен змінюватися між відображеннями, що виключає їх виклик у подібних місцях:
if / else
loops
nested functions
event handlers
Зберігайте їх на верхньому рівні компонентів та користувацьких хуків; плагін React hooks lint забезпечує дотримання цього правила.
Простий useState у двадцяти рядках
Створення мінімальної версії уточнює принцип роботи механізму. Наведений нижче ескіз містить масив слотів для гачків та курсор. Кожен виклик useState займає слот, який знаходиться під курсором, ініціалізує його лише у разі порожнечі, повертає функцію для зміни значення, прив’язану до цього індексу, та переміщує курсор вперед:
let hooks = [];
let currentHook = 0;
function useState(initialValue) {
const index = currentHook;
if (hooks[index] === undefined) {
hooks[index] = {
state: initialValue
};
}
function setState(value) {
hooks[index].state =
typeof value === "function"
? value(hooks[index].state)
: value;
render();
}
const state = hooks[index].state;
currentHook++;
return [state, setState];
}
Є дві речі, які передбачаються, але не показані: render() має скинути значення currentHook до 0 перед повторним викликом компонента, а також має знову запустити його виконання. Якщо у нас є компонент наступного типу:
function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Salim");
return ...;
}
структура зберігання виглядатиме так:
hooks[0]
state = 0
hooks[1]
state = "Salim"
Під час наступного відображення кожен виклик знаходить свій слот знову, ґрунтуючись виключно на його положенні.
Де закінчується ця спрощена модель
Real React додає об’єкти хуків до Fibers, черги оновлень, планувальник із пріоритетними каналами, механізми монтування та оновлення диспетчерів, логіку узгодження та затвердження змін; глобальних масивів немає. Основна ідея залишається незмінною:
Stable Hook order
+
React-managed Hook state
+
Component Fiber
Як працює useState у React
Для цього виклику:
const [count, setCount] = useState(0);
React потребує двох речей для кожного хука стану:
State
+
Update queue
У вигляді ілюстрації хук зберігає своє поточне значення у memoizedState, а також чергу оновлень, які ще не виконані:
Hook
├── memoizedState → 0
│
└── queue
└── pending updates
Перше відображення: монтування
Якщо у нас є такий компонент:
function Counter() {
const [count, setCount] = useState(0);
return <button>{count}</button>;
}
під час початкового відображення створюється хук, зберігається початкове значення, створюється порожня черга та повертається ця пара:
Component renders
↓
useState(0)
↓
Create Hook
↓
Store initial state
↓
Create update queue
↓
Return [0, setCount]
Тепер Fiber виглядає приблизно так, і цей етап називається монтуванням:
Fiber
│
└── Hook
├── state: 0
└── queue
Подальші відображення: оновлення
Припустимо, обробник подій викликає:
setCount(5);
React планує оновлення. Під час наступного відображення useState нічого не створює; він знаходить наявний хук, застосовує записані оновлення та повертає результат:
Component renders
↓
useState(0)
↓
React finds existing Hook
↓
Process queued updates
↓
New state = 5
↓
Return [5, setCount]
Тож 0, який ви передаєте, є початковим значенням, а не способом скидання. Це має значення лише під час першого створення хука.
setState — це запит, а не присвоєння
Такий виклик:
setCount(5);
не означає „встановити count у 5 прямо зараз“. Він записує запит, який React обробляє за власним графіком:
setCount(5)
↓
Create update
↓
Add update to queue
↓
Schedule React work
↓
Render component
↓
Process update queue
↓
Calculate new state
↓
Reconcile UI
↓
Commit changes
Оновлення записуються в чергу в порядку
Розгляньмо три послідовні функціональні оновлення:
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
React зберігає їх у черзі, починаючи з поточного значення:
Update Queue
c => c + 1
c => c + 1
c => c + 1
If the current state is: 0
і застосовує їх одне за одним:
0 → 1
1 → 2
2 → 3
Результат дорівнює 3, саме тому функціональні оновлення підходять для оновлень, які залежать від попереднього значення.
Чому передача значень поводиться інакше
Порівняйте версію, яка передає значення:
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
Якщо поточний відображення має count, дорівнюючий 0, усі три вирази обчислюються перед обробкою будь-якого оновлення, тож вони дають наступний результат:
setCount(1)
setCount(1)
setCount(1)
Кінцевий стан дорівнює 1. За допомогою функцій-оновлювачів:
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
кожна функція отримує результат попередньої, що дає 0, 1, 2, а в кінцевому підсумку 3.
Стан — це знімок одного відображення
Цей обробник дивує багатьох розробників:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(1);
console.log(count);
}
return <button onClick={handleClick}>{count}</button>;
}
У журналі виводиться 0, а не 1. count — це константа, яка належить до відображення, що створило обробник; виклик функції-змінювача не змінює її:
Current render
count = 0
↓
setCount(1)
↓
Update scheduled
↓
Current render still has count = 0
↓
Next render gets count = 1
Замикання пояснюють принцип знімка
Ця поведінка є класичною семантикою замикання JavaScript. Подивіться:
function Counter() {
const [count, setCount] = useState(0);
function logCount() {
console.log(count);
}
return <button onClick={logCount}>Log</button>;
}
Кожна обробка визначає нове значення logCount, причому кожна з них фіксує значення count своєї власної обробки:
Render #1
count = 0
└── logCount closes over 0
Render #2
count = 1
└── logCount closes over 1
Старий калебек завжди бачить значення своєї власної обробки, що є причиною більшості помилок із застарілими значеннями.
Як useEffect пам’ятає свої залежності
Візьмемо базовий ефект:
useEffect(() => {
console.log(count);
}, [count]);
Для цього React мусить відстежувати три речі:
Effect function
Dependencies
Cleanup function
Внутрішньо вони зберігаються у гуці ефекту під назвами, схожими на ці:
Hook
├── create → effect function
├── deps → [count]
└── destroy → cleanup
Під час кожної обробки React порівнює новий масив залежностей із збереженим.
Порівняння залежностей
Використовуючи той самий ефект:
useEffect(() => {
console.log(count);
}, [count]);
під час першої обробки записується:
count = 0
deps = [0]
а під час наступної обробки створюється:
count = 1
deps = [1]
React порівнює масиви елемент за елементом:
Previous: [0]
New: [1]
Changed
↓
Effect needs to run
Щось змінилося, тому виконується ефект. Якщо наступне відображення дає ті самі значення:
Previous: [1]
New: [1]
Same
↓
Effect can be skipped
ефект пропускається. Кожен елемент порівнюється за допомогою Object.is, що означає, що об’єкт або масив, створений під час відображення, вважається зміненим щоразу.
Очищення
Ефект може повертати функцію, яка скасовує його дії:
useEffect(() => {
const id = setInterval(() => {
console.log("tick");
}, 1000);
return () => {
clearInterval(id);
};
}, []);
Ця повернута функція є для очищення. Життєвий цикл інтервалу виглядає так:
Effect setup
↓
Interval created
↓
...
Cleanup
↓
Interval removed
Коли залежності можуть змінюватися, очищення стає ще важливішим:
useEffect(() => {
subscribe(userId);
return () => {
unsubscribe(userId);
};
}, [userId]);
При кожній зміні userId React розбирає попередній ефект перед тим, як запустити новий, і виконує остаточне очищення під час знешкодження компонента:
Old effect
↓
Cleanup
↓
New effect
Ефекти синхронізуються, вони не є методами життєвого циклу
Поширеним способом є розглядати useEffect із порожнім масивом як componentDidMount. Кориснішим підходом є уявлення про те, що ефект підтримує синхронність React із якоюсь системою поза межами React, наприклад:
Network requests
Timers
Subscriptions
Browser APIs
DOM APIs
Third-party libraries
WebSocket connections
Наприклад, з’єднання у чаті:
useEffect(() => {
const connection = connect(roomId);
return () => {
connection.disconnect();
};
}, [roomId]);
Коли змінюється roomId, ефект залишає стару кімнату та приєднується до нової, тож з’єднання завжди відповідає поточним параметрам.
useRef: один стабільний об’єкт
Реф створюється так:
const ref = useRef(0);
React повертає абсолютно той самий об’єкт під час кожного оновлення:
Render #1
ref → Object A
Render #2
ref → Object A
Render #3
ref → Object A
Внаслідок цього присвоєння йому:
ref.current = 100;
не викликає оновлення, на відміну від оновлення стану:
setCount(100);
яке його заплановує. Коротко кажучи, різниця у тому:
useState
├── React-managed state
├── update schedules render
└── used for UI state
useRef
├── stable object
├── mutable .current
└── changing .current doesn't schedule render
Типове застосування — зберігання ідентифікатора таймера:
function Timer() {
const timerRef = useRef(null);
function start() {
timerRef.current = setInterval(() => {
console.log("tick");
}, 1000);
}
return <button onClick={start}>Start</button>;
}
ID має зберігатися, але ніколи не повинен з’являтися на екрані — це класичне завдання для референсу.
useMemo та useCallback
Обчислення, яке було збережене у пам’яті, виглядає так:
const result = useMemo(
() => expensiveCalculation(data),
[data]
);
Хук концептуально зберігає останній результат та залежності, які використовувалися для його обчислення:
Hook
├── memoizedValue
└── dependencies
Під час першого відображення виконується обчислення:
data = A
calculate(A)
↓
result = X
Якщо під час наступного відображення надходять ті самі дані:
data = A
залежності залишаються незмінними:
A → A
тож повертається збережений результат X. Коли data стає B, обчислення виконується знову.
Розглядайте це як оптимізацію, а не як гарантію: React може видалити кеш, тому код має залишатися коректним навіть після перерозрахунку значень. Використовуйте цей підхід для ресурсомістких операцій або коли важливий стабільний референс, а не як стандартну практику.
useCallback застосовує ту саму ідею до функцій:
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
Він зберігає функцію та її залежності:
Hook
├── memoized function
└── dependencies
Змінені залежності повертають нову посилання на функцію; незмінені — те саме посилання. Це має значення переважно тоді, коли колбек передається до дочірнього елемента, обгорнутого у React.memo, адже нове посилання скасує ефект мемоізації:
const Child = React.memo(ChildComponent);
Кастомні хуки — це просто функції
Ось невеликий кастомний хук:
function useCounter() {
const [count, setCount] = useState(0);
function increment() {
setCount(c => c + 1);
}
return {
count,
increment
};
}
Його використовує компонент:
function App() {
const { count, increment } = useCounter();
return (
<button onClick={increment}>
{count}
</button>
);
}
Кастомний хук — це не окремий компонент. Це звичайна функція, створена на основі інших хуків, а його useState приєднується до того компонента, який наразі відображається:
App Fiber
↓
useCounter()
↓
useState()
↓
Hook state
Отже, два компоненти, які використовують useCounter, отримують два незалежні лічильники.
Диспетчери: один API, два способи роботи
Коли ви викликаєте useState, ця публічна функція передає запит тому диспетчеру, який React встановив для поточного відображення:
useState()
↓
Current Dispatcher
↓
mountState() / updateState()
Під час першого відображення це є реалізація функції mount:
Mount
↓
mountState()
а під час наступних відображень — реалізація функції update:
Update
↓
updateState()
Mount проти update для одного хука
Для цього виклику:
const [count, setCount] = useState(0);
функція mount створює необхідні структури:
Create Hook
↓
Store initial state
↓
Create update queue
тоді як функція update використовує їх знову:
Find existing Hook
↓
Process pending updates
↓
Calculate new state
↓
Return state + dispatcher
Підсумовуючи:
FIRST RENDER
↓
Mount Hooks
SUBSEQUENT RENDERS
↓
Update Hooks
Етап відображення та етап підтвердження
Хуки найкраще працюють у рамках двоетапної моделі відображення React:
State / Props Update
↓
Render Phase
↓
Reconciliation
↓
Commit Phase
↓
DOM
Під час відображення React викликає компоненти та обчислює наступний інтерфейс користувача:
Components execute
Hooks are processed
New UI is calculated
Під час підтвердження він застосовує отриманий результат:
DOM mutations happen
Refs are updated
Layout effects are handled
У сучасному React можливе призупинення, перезапуск або відкидання операцій відображення, тоді як commit завжди завершується, саме тому логіка відображення має бути чистою.
Збереження чистоти відображення
Уникайте побічних ефектів безпосередньо в тілі компонента:
function Component() {
fetch("/api/users"); // ❌
return <div>Hello</div>;
}
Операція відображення може виконуватися багаторазово або відкидатися перед commit, тому цей запит може бути викликаний багато разів. Замість цього використовуйте один із наступних варіантів:
Event handler
useEffect
Framework data-loading mechanism
Відображення має обчислювати інтерфейс лише на основі вхідних даних:
UI = f(props, state)
Об’єднання всього в один крок
Ось компонент, який використовує стан та ефект:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
);
}
Клік проходить через усі описані вище етапи:
Click
↓
setCount()
↓
Update added to Hook queue
↓
React schedules work
↓
Counter renders
↓
useState retrieves/processes state
↓
useEffect checks dependencies
↓
React reconciles new UI
↓
Commit DOM changes
↓
Effect runs
Групування операцій та причина, чому DOM не оновлюється миттєво
Обробник, який змінює кілька значень стану:
function handleClick() {
setCount(c => c + 1);
setName("Salim");
setVisible(true);
}
це не спричиняє трьох окремих відображень. З React 18 оновлення автоматично об’єднуються в групи, включаючи ті, що знаходяться у таймаутах та обіцянках:
setCount()
setName()
setVisible()
↓
Batch
↓
Render
↓
Commit
замість того, щоб:
Update → Render → Commit
Update → Render → Commit
Update → Render → Commit
Об’єднання саме по собі є способом уникнення зайвої роботи.
Та сама логіка пояснює, чому такий виклик, як:
setCount(10);
не означає одразу:
state = 10
DOM = 10
Натомість він проходить через цей потік обробки:
setCount(10)
↓
Schedule update
↓
React render
↓
Reconciliation
↓
Commit
↓
DOM update
Відкладення дає React можливість об’єднувати, пріоритезувати та пропускати зайву роботу; flushSync з react-dom змушує виконати синхронне оновлення лише тоді, коли це дійсно необхідно.
Весь механізм у загальних рисах
Усе вищесказане укладається в одну картину, від компонента до пікселів на екрані:
FUNCTION COMPONENT
│
▼
Fiber
│
▼
Hook sequence
│
┌───────────────┼───────────────┐
▼ ▼ ▼
useState useRef useEffect
│ │ │
▼ ▼ ▼
Update Queue Stable Object Dependencies
│
▼
setState()
│
▼
Schedule Update
│
▼
Render
│
▼
Reconciliation
│
▼
Commit
│
▼
DOM
│
▼
Browser
│
▼
Pixels
Основні висновки
- Стан хука зберігається у Fiber компонента та прив’язується до кожного виклику за позицією, тому порядок хуків ніколи не повинен змінюватися.
setStateдодає оновлення у чергу; нове значення з’являється під час наступного відображення, а поточне відображення зберігає свою копію стану.- Використовуйте функціональні оновлення кожного разу, коли оновлення залежить від попереднього значення.
- Ефекти синхронізуються з зовнішніми системами; залежності порівнюються за допомогою
Object.is, а очищення виконується перед повторним запуском та під час видалення компонента. - Референи — це стабільні змінні контейнери, які ніколи не викликають переробки; мемоїзація є оптимізацією, а не гарантією.
- Кастомні хуки ділять логіку, а не стан, і процес відображення має залишатися чистим, оскільки React може його повторити або проігнорувати.