Постановка в очередь серии обновления состояний

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

You will learn

  • Что такое “группировка” и как React иcпользует её для обработки множества обновлений состояния
  • Как назначить несколько обновлений к одной и той же переменной состояния подряд

React группирует обновления состояния

Вы можете ожидать, что нажимая на кнопку “+3”, вы увеличите значение счетчика трижды, т.к. setNumber(number + 1) вызывается три раза:

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}>+3</button>
    </>
  )
}

Кроме того, как вы, возможно, помните из прошлой секции, значения состояния фиксированы во время рендера, значение number внутри обработчика событий первого рендера всегда равно 0, независимо от того, сколько раз вы вызвали функцию setNumber(1):

setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);

Кроме этого, есть еще один важный фактор. React будет ждать, пока весь код во всех обработчиках событий отработает, перед тем как выполнить обновление состояния. Вот почему ре-рендер происходит только после всех вызовов setNumber().

Для примера вспомним официанта, который принимает заказ в ресторане. Официант не бежит на кухню сразу после того, как услышал первое блюдо! Вместо этого он ждет пока вы закончите свой заказ, уточняет его детали, и даже принимает заказы от других людей за столом.

An elegant cursor at a restaurant places and order multiple times with React, playing the part of the waiter. After she calls setState() multiple times, the waiter writes down the last one she requested as her final order.

Illustrated by Rachel Lee Nabors

Это позволяет нам обновлять несколько переменных состояния—даже от нескольких компонентов—без вызова слишком большого количества ре-рендеров. Но это также означает, что UI не будет обновлен до того, как ваши обработчики событий, и код в них, не исполнится. Это поведение, также известное как группировка, позволяет вашему React-приложению работать гораздо быстрее. Это также позволяет избегать сбивающих с толку “наполовину законченных” рендеров, где обновились только некоторые переменные.

React не группирует по множеству преднамеренных событий вроде кликов— каждый клик обрабыватывается отдельно. В остальном будьте уверены, что React группирует только тогда, когда это в общем безопасно для выполнения. Это гарантирует, например, что если первый клип по кнопке отключает форму, следующий клик не отправит ее снова.

Обновления одного и того же состояния несколько раз до следующего рендера

Это не такой распространенный вариант использования, но если вы захотите обновить одну и ту же переменную состояния несколько раз до следующего рендера, вместо того чтобы передавать следующее значение состояния в виде setNumber(number + 1), вы можете передать функцию, которая подсчитывает следующее состояние, базируясь на предыдущем в очереди, типа setNumber(n => n + 1). Это возможность сказать React “сделай что-то со значением состояния” вместо того, чтобы просто его заменить.

Попробуем увеличить значение счетчика сейчас:

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(n => n + 1);
        setNumber(n => n + 1);
        setNumber(n => n + 1);
      }}>+3</button>
    </>
  )
}

Здесь, в n => n + 1 вызывается обновляющая функция. Когда вы передаете ее в установщик состояния:

  1. React назначает эту функцию в очередь, которая выполнится после всего остального кода в обработчике событий.
  2. Во время следующего рендера React запустит очередь и выдаст вам финальное обновление состояния.
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);

Вот как React обработает эти строчки кода во время выполнения обработчика событий:

  1. setNumber(n => n + 1): n => n + 1 это функция. React добавляет её в очередь
  2. setNumber(n => n + 1): n => n + 1 это функция. React добавляет её в очередь
  3. setNumber(n => n + 1): n => n + 1 это функция. React добавляет её в очередь

Когда вы вызываете useState в следующем рендере, React проходит через эту очередь. Предыдущее состояние number было 0, так что функция принимает именно это значение в следующем обновлении как n, и так далее:

запланированное обновлениеnвозвращает
n => n + 100 + 1 = 1
n => n + 111 + 1 = 2
n => n + 122 + 1 = 3

React сохранит 3 как финальный результат и вернет его из useState.

Вот почему нажатие на “+3” в примере выше корректно увеличивает значение на 3.

Что случится, если вы обновите состояние после его замены

Что насчет обработчика событий? Как вы полагаете, какое значение примет number в следующем рендере?

<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
}}>
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
      }}>Увеличить число</button>
    </>
  )
}

Вот что обработчик событий говорит сделать React:

  1. setNumber(number + 5): number равен 0, так что setNumber(0 + 5). React добавит “заменить на 5 в свою очередь.
  2. setNumber(n => n + 1): n => n + 1 это обновляющая функция. React добавит эту функцию в свою очередь.

В следующем рендере React пройдет через следующую очередь состояний:

очередь обновленийnвозвращает
“заменить на 50 (не используется)5
n => n + 155 + 1 = 6

React сохранит 6 как финальный результат и вернет его из useState.

Note

Вы могли заметить что setState(5) на самом деле работает как setState(n => 5), но n не используется!

Что случится, если вы замените состояние после его обновления

Давайте посмотрим еще один пример. Как вы думаете, какое значение примет number в следующем рендере?

<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
setNumber(42);
}}>
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
        setNumber(42);
      }}>Увеличить число</button>
    </>
  )
}

Вот как React обработает этот код во время выполнения обработчиков событий:

  1. setNumber(number + 5): number равен 0, так что setNumber(0 + 5). React добавит “заменить на 5 в свою очередь.
  2. setNumber(n => n + 1): n => n + 1 это функция обновления. React добавит эту функцию в свою очередь.
  3. setNumber(42): React добавит “заменить на 42 в очередь.

В следующем рендере React пройдет через следующие обновления состояния:

очередь обновленияnвозвращает
“заменить на 50 (не используется)5
n => n + 155 + 1 = 6
“заменить на 426 (не используется)42

После React сохранит 42 как финальный результат и вернет его из useState.

Подводя итог, вот что вы можете передавать в установщик состояния setNumber:

  • Обновляющую функцию (напр. n => n + 1), которая добавляется в очередь.
  • Любое другое значение (напр. число 5), которое добавит “заменить на 5” в очередь, игнорируя то, что уже было запланировано.

После того как все обработчики событий закончатся, React запустит ре-рендер. Во время ре-рендера React обработает очередь. Обновляющие функции выполняются во время рендеринга, так что они должны быть чистыми и возвращать только результат. Не пытайтесь устанавливать состояние изнутри или запускать другие побочные эффекты. В строгом режиме React будет запускать каждую обновляющую функцию дважды (но не учитывать второй результат), чтобы помочь вам найти ошибки.

Соглашения об именах

Достаточно часто аргумент обновляющей функции обозначают первыми буквами связанной с ней переменной состояния:

setEnabled(e => !e);
setLastName(ln => ln.reverse());
setFriendCount(fc => fc * 2);

Если вы предпочитаете более многословный вариант, другим распространенным соглашенением является повторение полного наименования переменной состояния, типа setEnabled(enabled => !enabled), или же использование префикса типа setEnabled(prevEnabled => !prevEnabled).

Recap

  • Установка состояния не изменяет переменную в текущем рендере, но запрашивает новый рендер.
  • React выполнит обновления состояния после того, как обработчики событий успешно выполнились. Это называется группировка.
  • Чтобы обновить любое состояние множество раз за одно событие, можно использовать функцию обновления setNumber(n => n + 1).

Challenge 1 of 2:
Исправление счетчика запросов

Вы работаете над интернет-магазином произведений искусства, в котором пользователю можно делать множество заказов предметов искусства за один раз. Каждый раз когда пользователь нажимает кнопку “Купить”, счетчик “Ожидание” должен увеличиться на единицу. После 3 секунд, счётчик “Ожидание” должен уменьшаться, а счетчик “Выполнено” должен увеличиваться.

Однако, счетчик “Ожидание” не работает как задумано. Когда вы нажимаете “Купить”, значение уменьшается до -1(чего вообще не должно быть). И если вы быстро кликните дважды, оба счетчика сработают непредсказуемо.

Почему это происходит? Давайте починим оба счетчика.

import { useState } from 'react';

export default function RequestTracker() {
  const [pending, setPending] = useState(0);
  const [completed, setCompleted] = useState(0);

  async function handleClick() {
    setPending(pending + 1);
    await delay(3000);
    setPending(pending - 1);
    setCompleted(completed + 1);
  }

  return (
    <>
      <h3>
        Ожидание: {pending}
      </h3>
      <h3>
        Выполнено: {completed}
      </h3>
      <button onClick={handleClick}>
        Купить     
      </button>
    </>
  );
}

function delay(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}