Постановка у чергу серії оновлень стану

Встановлення змінної стану поставить у чергу інший рендер. Але іноді вам може знадобитися виконати декілька операцій над значенням перед тим, як поставити у чергу наступний рендер. Для цього корисно зрозуміти, як React пакетує оновлення стану.

  • Що таке "batching" і як 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>
    </>
  )
}
button { display: inline-block; margin: 10px; font-size: 20px; }
h1 { display: inline-block; margin: 10px; width: 30px; text-align: center; }

Втім, як ви могли пам'ятати з попереднього розділу, значення стану кожного рендеру є фіксованими, тому значення числа в обробнику події першого рендеру завжди буде 0, незалежно від того, скільки разів ви викликали setNumber(1):

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

Але є ще один фактор. React чекає, поки відпрацює весь код в обробниках подій, перш ніж обробляти оновлення вашого стану. Ось чому повторний рендеринг відбувається лише після всіх цих setNumber() викликів.

Це може нагадати вам офіціанта, який приймає замовлення у ресторані. Офіціант не біжить на кухню при згадці вашої першої страви! Замість цього, він дає вам закінчити ваше замовлення, дозволяє вносити в нього зміни і навіть приймає замовлення від інших людей за столом.

Це дозволяє вам оновлювати декілька змінних стану - навіть з декількох компонентів - не викликаючи надто багато повторних рендерингів. Але це також означає, що інтерфейс не буде оновлено доти, доки не завершиться ваш обробник події та будь-який код у ньому. Така поведінка, також відома як пакетування, робить ваш 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>
    </>
  )
}
button { display: inline-block; margin: 10px; font-size: 20px; }
h1 { display: inline-block; margin: 10px; width: 30px; text-align: center; }

Тут 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. .
  3. setNumber(n => n + 1): n => n + 1 - це функція. React додає її до черги.
  4. setNumber(n => n + 1): n => n + 1 - це функція. React додає її до черги.

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

поставити в чергу на оновлення n returns
n => n + 1 0 0 + 1 = 1
n => n + 1 1 1 + 1 = 2
n => n + 1 2 2 + 1 = 3

React зберігає 3 як кінцевий результат і повертає його з useState.

Ось чому натискання "+3" у наведеному вище прикладі правильно збільшує значення на 3.

Що станеться, якщо оновити стан після його заміни

Що з цим обробником подій? Як ви думаєте, яким буде число при наступному рендерингу?

<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);
      }}>Increase the number</button>
    </>
  )
}
button { display: inline-block; margin: 10px; font-size: 20px; }
h1 { display: inline-block; margin: 10px; width: 30px; text-align: center; }

Ось що цей обробник події каже робити React:

  1. setNumber(number + 5): число дорівнює 0, тому setNumber(0 + 5). React додає "замінити на 5" до своєї черги.
  2. setNumber(n => n + 1): n => n + 1 є функцією оновлення. React додає цю функцію до своєї черги.

Під час наступного рендерингу React проходить через чергу станів:

поставити в чергу на оновлення n returns
"замінити на 5" 0 (не використовується) 5
n => n + 1 5 5 + 1 = 6

React зберігає 6 як кінцевий результат і повертає його з useState.

Ви могли помітити, що setState(5) насправді працює як setState(n => 5), але n не використовується!

Що станеться, якщо замінити стан після його оновлення

Спробуймо ще один приклад. Як ви думаєте, яким буде число у наступному рендерингу?

<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);
      }}>Increase the number</button>
    </>
  )
}
button { display: inline-block; margin: 10px; font-size: 20px; }
h1 { display: inline-block; margin: 10px; width: 30px; text-align: center; }

Ось як працює React у цих рядках коду під час виконання обробника події:

  1. setNumber(number + 5): число дорівнює 0, тому setNumber(0 + 5). React додає "замінити на 5" до своєї черги.
  2. setNumber(n => n + 1): n => n + 1 - функція оновлення. React додає цю функцію до своєї черги.
  3. setNumber(42): React додає "замінити на 42" до своєї черги.

Під час наступного рендерингу React проходить через чергу станів:

поставити в чергу на оновлення n returns
"замінити на 5" 0 (не використовується) 5
n => n + 1 5 5 + 1 = 6
"замінити на 42" 6 (не використовується) 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).

  • Стан налаштування не змінює змінну у наявному рендерингу, але запитує новий рендеринг.
  • React обробляє оновлення стану після завершення роботи обробників подій. Це називається пакетною обробкою.
  • Щоб оновити деякий стан декілька разів за одну подію, можна скористатися функцією setNumber(n => n + 1) updater.

Виправити лічильник запитів

Ви працюєте над додатком для арт-маркету, який дозволяє користувачеві подавати кілька замовлень на арт-об'єкт одночасно. Кожного разу, коли користувач натискає кнопку "Купити", лічильник "Очікування" повинен збільшуватися на одиницю. Через три секунди лічильник "Очікування" має зменшитися, а лічильник "Завершено" - збільшитися.

Проте лічильник "Очікує" поводиться не так, як передбачалося. Коли ви натискаєте "Купити", він зменшується до -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: {pending}
      </h3>
      <h3>
        Completed: {completed}
      </h3>
      <button onClick={handleClick}>
        Buy     
      </button>
    </>
  );
}

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

У обробнику події handleClick значення pending та completed відповідають тим, якими вони були на момент події кліку. Для першого рендерингу pending був 0, тому setPending(pending - 1) стає setPending(-1), що неправильно. Оскільки ви хочете збільшити або зменшити лічильники, а не встановити їх у конкретне значення, визначене під час кліку, ви можете замість цього передати функції оновлення:

import { useState } from 'react';

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

  async function handleClick() {
    setPending(p => p + 1);
    await delay(3000);
    setPending(p => p - 1);
    setCompleted(c => c + 1);
  }

  return (
    <>
      <h3>
        Pending: {pending}
      </h3>
      <h3>
        Completed: {completed}
      </h3>
      <button onClick={handleClick}>
        Buy     
      </button>
    </>
  );
}

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

Це гарантує, що коли ви збільшуєте або зменшуєте лічильник, ви робите це відносно його останнього стану, а не того, яким він був на момент кліку.

Реалізуйте чергу станів самостійно

У цьому челенджі ви перепишете крихітну частину React з нуля! Це не так складно, як здається.

Прокрутіть попередній перегляд пісочниці. Зверніть увагу, що у ньому показано чотири тестових приклади. Вони відповідають прикладам, які ви бачили раніше на цій сторінці. Ваше завдання - реалізувати функцію getFinalState так, щоб вона повертала правильний результат для кожного з цих випадків. Якщо ви реалізуєте її правильно, всі чотири тести повинні бути вдалими.

Ви отримаєте два аргументи: baseState - початковий стан (як 0), а черга - масив, який містить набір чисел (як 5) та функцій оновлення (як n => n + 1) у порядку їх додавання.

Ваше завдання - повернути кінцевий стан, як показано в таблицях на цій сторінці!

Якщо ви відчуваєте, що застрягли, почніть з такої структури коду:

export function getFinalState(baseState, queue) {
  let finalState = baseState;

  for (let update of queue) {
    if (typeof update === 'function') {
      // TODO: apply the updater function
    } else {
      // TODO: replace the state
    }
  }

  return finalState;
}

Заповніть пропущені рядки!

export function getFinalState(baseState, queue) {
  let finalState = baseState;

  // TODO: do something with the queue...

  return finalState;
}
import { getFinalState } from './processQueue.js';

function increment(n) {
  return n + 1;
}
increment.toString = () => 'n => n+1';

export default function App() {
  return (
    <>
      <TestCase
        baseState={0}
        queue={[1, 1, 1]}
        expected={1}
      />
      <hr />
      <TestCase
        baseState={0}
        queue={[
          increment,
          increment,
          increment
        ]}
        expected={3}
      />
      <hr />
      <TestCase
        baseState={0}
        queue={[
          5,
          increment,
        ]}
        expected={6}
      />
      <hr />
      <TestCase
        baseState={0}
        queue={[
          5,
          increment,
          42,
        ]}
        expected={42}
      />
    </>
  );
}

function TestCase({
  baseState,
  queue,
  expected
}) {
  const actual = getFinalState(baseState, queue);
  return (
    <>
      <p>Base state: <b>{baseState}</b></p>
      <p>Queue: <b>[{queue.join(', ')}]</b></p>
      <p>Expected result: <b>{expected}</b></p>
      <p style={{
        color: actual === expected ?
          'green' :
          'red'
      }}>
        Your result: <b>{actual}</b>
        {' '}
        ({actual === expected ?
          'correct' :
          'wrong'
        })
      </p>
    </>
  );
}

Це саме той алгоритм, описаний на цій сторінці, який React використовує для обчислення кінцевого стану:

export function getFinalState(baseState, queue) {
  let finalState = baseState;

  for (let update of queue) {
    if (typeof update === 'function') {
      // Apply the updater function.
      finalState = update(finalState);
    } else {
      // Replace the next state.
      finalState = update;
    }
  }

  return finalState;
}
import { getFinalState } from './processQueue.js';

function increment(n) {
  return n + 1;
}
increment.toString = () => 'n => n+1';

export default function App() {
  return (
    <>
      <TestCase
        baseState={0}
        queue={[1, 1, 1]}
        expected={1}
      />
      <hr />
      <TestCase
        baseState={0}
        queue={[
          increment,
          increment,
          increment
        ]}
        expected={3}
      />
      <hr />
      <TestCase
        baseState={0}
        queue={[
          5,
          increment,
        ]}
        expected={6}
      />
      <hr />
      <TestCase
        baseState={0}
        queue={[
          5,
          increment,
          42,
        ]}
        expected={42}
      />
    </>
  );
}

function TestCase({
  baseState,
  queue,
  expected
}) {
  const actual = getFinalState(baseState, queue);
  return (
    <>
      <p>Base state: <b>{baseState}</b></p>
      <p>Queue: <b>[{queue.join(', ')}]</b></p>
      <p>Expected result: <b>{expected}</b></p>
      <p style={{
        color: actual === expected ?
          'green' :
          'red'
      }}>
        Your result: <b>{actual}</b>
        {' '}
        ({actual === expected ?
          'correct' :
          'wrong'
        })
      </p>
    </>
  );
}

Тепер ви знаєте, як працює ця частина React!