React useState не обновляет значение сразу: почему так происходит и как исправить

mr. Cooper 1 неделю назад Веб-разработка
React useState не обновляет значение сразу: почему так происходит и как исправить

Почему useState не обновляет значение сразу? На практике проблема обычно проявляется так: после setCount() React уже показывает новое значение, но console.log(count) продолжает выводить старое. Я сам несколько раз ловил себя на том, что воспринимал это как ошибку в коде, хотя React в этот момент работает именно так, как задумано.

const [count, setCount] = useState(0);


function increment() {
  setCount(count + 1);


  console.log(count);
}

Кнопка увеличивает число на странице, но console.log() сразу после setCount() показывает старое значение.

Например, после первого клика можно получить:

На странице: 1
В консоли: 0

Причём со счётчиком всё работает нормально. React не проигнорировал setCount() и не «завис» между двумя значениями.

Проблема в другом: count внутри этого обработчика относится к текущему рендеру.

Почему React показывает новое значение, а console.log - старое

Когда компонент отрисовывается, React передаёт ему определённое значение state.

Допустим:

const [count, setCount] = useState(0);

Для этого рендера count равен 0.

Дальше вызывается:

setCount(1);

React получает запрос на изменение состояния. Но переменная count, с которой уже работает текущий обработчик, от этого не становится 1.

Поэтому:

function increment() {
  setCount(1);

  console.log(count);
}

выведет:

0

После нового рендера компонент уже получит:

count = 1

Именно это React называет моделью state как снимка: каждый рендер получает своё значение состояния, а код внутри этого рендера продолжает работать с ним. Документация React о state как снимке

Для меня именно этот момент оказался самым важным. После него setCount() перестаёт выглядеть как обычное присваивание переменной.

Где это действительно может привести к ошибке

С console.log() проблема скорее раздражает, чем ломает приложение.

А вот несколько обновлений подряд уже могут дать неожиданный результат.

Допустим, нужно увеличить число на три:

function increment() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

Можно ожидать:

0 → 3

Но результатом будет:

0 → 1

Если count в текущем рендере равен 0, все три строки фактически рассчитывают одно и то же значение:

setCount(1);
setCount(1);
setCount(1);

То есть React получает три обновления с одинаковым результатом.

В документации React есть практически такой же пример с кнопкой, которая должна увеличивать значение на три. Пример React с несколькими обновлениями state

И вот здесь уже появляется правильный способ обновления.

Когда новое значение зависит от предыдущего

Вместо:

setCount(count + 1);

можно передать функцию:

setCount(prevCount => prevCount + 1);

Теперь предыдущий пример будет работать ожидаемо:

function increment() {
  setCount(prevCount => prevCount + 1);
  setCount(prevCount => prevCount + 1);
  setCount(prevCount => prevCount + 1);
}

Получим:

0 → 1 → 2 → 3

Здесь React последовательно применяет updater-функции. Следующая получает результат предыдущей. Документация React о updater-функциях

Поэтому я обычно смотрю на обновление state довольно просто.

Если новое значение известно само по себе:

setCount(10);

Если его нужно рассчитать на основе предыдущего:

setCount(prevCount => prevCount + 1);

Второй вариант особенно полезен, когда обновлений может быть несколько.

С массивами и объектами принцип тот же

Например, добавление элемента:

setItems(prevItems => [
  ...prevItems,
  newItem
]);

Удаление:

setItems(prevItems =>
  prevItems.filter(item => item.id !== id)
);

Изменение объекта:

setUser(prevUser => ({
  ...prevUser,
  age: prevUser.age + 1
}));

Во всех случаях новое состояние рассчитывается из предыдущего.

И ещё один момент, о котором легко забыть: массивы и объекты в state не стоит изменять напрямую. React ожидает новое значение состояния, а не изменение существующего объекта или массива. Документация React об обновлении объектов в state

А если новое значение нужно использовать сразу?

Вот здесь я раньше чаще всего и пытался «поймать» новое значение через state:

function increment() {
  setCount(count + 1);

  doSomething(count);
}

Но doSomething() получит старый count.

Если новое значение уже можно вычислить, проще сохранить его в обычную переменную:

function increment() {
  const nextCount = count + 1;

  setCount(nextCount);

  doSomething(nextCount);
}

Теперь не нужно ждать нового рендера.

У нас есть текущее состояние:

count = 0

и значение, которое мы хотим установить:

nextCount = 1

Для текущей функции этого достаточно.

setTimeout здесь не спасёт

Иногда встречается попытка решить проблему таким образом:

setCount(count + 1);

setTimeout(() => {
  console.log(count);
}, 0);

Но задержка ничего принципиально не меняет. Обработчик всё равно работает со значением state, которое было доступно ему во время текущего рендера.

Поэтому setTimeout() здесь не нужен. React отдельно показывает, что даже код внутри отложенного вызова продолжает видеть тот же snapshot состояния. Документация React о state как снимке

Если новое значение нужно в этой же функции:

const nextCount = count + 1;

setCount(nextCount);

doSomething(nextCount);

Если же нужно выполнить отдельную логику после изменения состояния, это уже другая задача - для неё можно использовать useEffect с соответствующим состоянием в зависимостях.

Почему эта мелочь становится важной в реальном коде

На счётчике всё выглядит довольно безобидно. Но тот же момент легко встретить в пагинации, фильтрах, формах или обработчиках запросов.

Например:

const [page, setPage] = useState(1);

function nextPage() {
  setPage(page + 1);

  loadPage(page);
}

Здесь loadPage(page) получит старое значение.

Если нам нужна именно следующая страница, можно сделать так:

function nextPage() {
  const nextPage = page + 1;

  setPage(nextPage);
  loadPage(nextPage);
}

А если изменение самого page зависит от предыдущего состояния и такие обновления могут идти подряд:

setPage(prevPage => prevPage + 1);

Но важно не перепутать эти два случая.

После:

setPage(prevPage => prevPage + 1);

console.log(page);

page всё равно относится к текущему рендеру.

Это не ошибка React. Просто новое значение ещё не стало значением переменной page в данном рендере.

Что я теперь учитываю при работе с useState

После нескольких таких ситуаций я перестал воспринимать setter как обычное присваивание.

Если мне нужно просто изменить состояние:

setCount(10);

Если новое значение зависит от старого:

setCount(prev => prev + 1);

Если новое значение нужно использовать ещё где-то в текущей функции:

const nextCount = count + 1;

setCount(nextCount);
doSomething(nextCount);

А вот это:

setCount(count + 1);

console.log(count);

не стоит использовать как способ получить новое значение.

count здесь всё ещё относится к тому рендеру, в котором был создан этот обработчик.

Когда это понимаешь, useState перестаёт казаться каким-то непредсказуемым механизмом. Просто у state в React другая модель работы, чем у обычной переменной JavaScript.

Комментарии

Пока нет комментариев. Будьте первым, кто напишет.

Чтобы оставить комментарий, войдите в аккаунт.

Похожие статьи