Создайте хук 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 обеспечивает соблюдение этого правила.
Упрощенная реализация 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 добавляет объекты хуков к Файберам, очереди обновлений, планировщик с приоритетными каналами, механизмы монтирования и обновления диспетчеров, логику синхронизации и фиксации изменений; глобальных массивов нет. Основная идея остаётся прежней:
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
│
└── 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
Старый кallback всегда видит значения своей собственной отрисовки, что является причиной большинства ошибок с устаревшими значениями.
Как 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: один стабильный объект
Объект ref создаётся следующим образом:
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>;
}
Идентификатор должен сохраняться, но никогда не отображаться на экране — это классическая задача для референсов.
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 работа по отрисовке может быть приостановлена, возобновлена или проигнорирована, тогда как операция коммита всегда завершается, поэтому логика отрисовки должна быть чистой.
Сохранение чистоты отрисовки
Избегайте побочных эффектов непосредственно в теле компонента:
function Component() {
fetch("/api/users"); // ❌
return <div>Hello</div>;
}
Отрисовка может выполняться несколько раз или проигнорироваться до коммита, поэтому этот запрос может сработать многократно. Вместо этого используйте один из следующих вариантов:
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 может его повторить или проигнорировать.