Главная / Статьи

Создайте хук useState для игрушек, чтобы понять снимки состояния, очереди и эффекты

Узнайте, как работают хуки React, создав простой состоянийный хук useState, а затем используйте эту модель для объяснения очередей обновлений, устаревших снимков состояния, замыканий, очистки эффектов, ссылок и группировки операций.

Издатель: REACTAPP.TOP14 мин чтения3107 слов

Источники: 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 может его повторить или проигнорировать.