Стан як знімок

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

  • Як встановлення стану викликає повторний рендеринг
  • Коли і як оновлюється стан
  • Чому стан не оновлюється одразу після його встановлення
  • Як обробники подій отримують доступ до "знімка" стану

Встановлення тригерів стану призводить до рендерингу

Ви можете думати, що ваш користувацький інтерфейс змінюється безпосередньо у відповідь на користувацьку подію, таку як клік. У React це працює трохи інакше, ніж ця ментальна модель. На попередній сторінці ви бачили, що стан запитує повторний рендер у React. Це означає, що для того, щоб інтерфейс відреагував на подію, вам потрібно оновити стан.

У цьому прикладі, коли ви натискаєте "відправити", setIsSent(true) каже React повторно відрендерити UI:

import { useState } from 'react';

export default function Form() {
  const [isSent, setIsSent] = useState(false);
  const [message, setMessage] = useState('Hi!');
  if (isSent) {
    return <h1>Your message is on its way!</h1>
  }
  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      setIsSent(true);
      sendMessage(message);
    }}>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}

function sendMessage(message) {
  // ...
}
label, textarea { margin-bottom: 10px; display: block; }

Ось що відбувається, коли ви натискаєте кнопку:

  1. Виконується обробник події onSubmit.
  2. setIsSent(true) встановлює isSent як true і ставить у чергу новий рендер.
  3. React повотрно відрендерить компонент відповідно до нового значення isSent.

Давайте детальніше розглянемо зв'язок між станом і рендерингом.

При рендерингу робиться знімок у часі

"Рендер" означає, що React викликає ваш компонент, який є функцією. JSX, який ви повертаєте з цієї функції, є знімком інтерфейсу в часі. Його пропси, обробники подій та локальні змінні були обчислені на основі його стану на момент рендерингу.

На відміну від фотографії або кадру фільму, "знімок" інтерфейсу, який ви повертаєте, є інтерактивним. Він включає логіку, як-от обробники подій, які визначають, що відбувається у відповідь на вхідні дані. React оновлює екран відповідно до цього знімка і підключає обробники подій. В результаті, натискання кнопки запускає обробник кліку з вашого JSX.

Коли React повторно рендерить компонент:

  1. React знову викличе вашу функцію.
  2. Ваша функція повертає новий знімок JSX.
  3. Після цього React оновить екран відповідно до знімка, який повернула ваша функція.

Як пам'ять компонента, стан не схожий на звичайну змінну, яка зникає після повернення вашої функції. Стан насправді "живе" в самому React - наче на полиці! - поза вашою функцією. Коли React викликає ваш компонент, він надає вам знімок стану для цього конкретного рендерингу. Ваш компонент повертає знімок інтерфейсу зі свіжим набором пропсів та обробників подій у своєму JSX, які обчислюються з використанням значень стану з цього рендерингу!

Ось невеликий експеримент, який покаже вам, як це працює. У цьому прикладі ви можете очікувати, що натискання кнопки "+3" збільшить лічильник утричі, оскільки вона тричі викликає setNumber(number + 1).

Подивіться, що відбувається, коли ви натискаєте кнопку "+3":

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. Ось чому у обробнику onClick цього рендеру значення числа все ще є 0 навіть після виклику setNumber(number + 1):

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

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

  1. setNumber(number + 1): число дорівнює 0 тому setNumber(0 + 1).
    • React готується змінити число на 1 під час наступного рендеру.
  2. setNumber(number + 1): число дорівнює 0 тому setNumber(0 + 1).
    • React готується змінити число на 1 під час наступного рендеру.
  3. setNumber(number + 1): число дорівнює 0 тому setNumber(0 + 1).
    • React готується змінити число на 1 під час наступного рендеру.

Хоча ви тричі викликали setNumber(number + 1), в обробнику події цього рендеру число завжди 0, тому ви тричі встановили стан в 1. Ось чому після завершення обробника події React повторно рендерить компонент з number, що дорівнює 1, а не 3.

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

<button onClick={() => {
  setNumber(0 + 1);
  setNumber(0 + 1);
  setNumber(0 + 1);
}}>+3</button>

Для наступного рендерингу число дорівнює 1, тому обробник кліку цього рендеру виглядає так:

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

Тому повторне натискання кнопки встановить лічильник на 2, наступне натискання - на 3 і так далі.

Стан з плином часу

Що ж, це було весело. Спробуйте здогадатися, про що сповістить натискання цієї кнопки:

import { useState } from 'react';

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

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        alert(number);
      }}>+5</button>
    </>
  )
}
button { display: inline-block; margin: 10px; font-size: 20px; }
h1 { display: inline-block; margin: 10px; width: 30px; text-align: center; }

Якщо ви використовуєте метод підстановки, то можете здогадатися, що попередження показує "0":

setNumber(0 + 5);
alert(0);

А що, якщо поставити таймер на сповіщення, щоб воно спрацьовувало лише після того, як компонент буде перерендерено? Буде сказано "0" чи "5"? Здогадайтеся!

import { useState } from 'react';

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

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setTimeout(() => {
          alert(number);
        }, 3000);
      }}>+5</button>
    </>
  )
}
button { display: inline-block; margin: 10px; font-size: 20px; }
h1 { display: inline-block; margin: 10px; width: 30px; text-align: center; }

Здивовані? Якщо ви скористаєтеся методом підстановки, то зможете побачити "знімок" стану, який передається в сповіщення.

setNumber(0 + 5);
setTimeout(() => {
  alert(0);
}, 3000);

Стан, збережений у React, міг змінитися до моменту запуску сповіщення, але його було заплановано з використанням знімка стану на момент взаємодії користувача з ним!

Значення змінної стану ніколи не змінюється під час рендеру, навіть якщо код обробника її події асинхронний. Усередині цього рендеру onClick значення числа продовжує бути 0 навіть після виклику setNumber(number + 5). Його значення було "зафіксовано", коли React "зробив знімок" інтерфейсу, викликавши ваш компонент.

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

  1. Ви натискаєте кнопку "Відправити", надсилаючи Алісі привіт.
  2. Перед закінченням п'ятисекундної затримки ви змінюєте значення поля "Кому" на "Боб".

Що ви очікуєте побачити у сповіщенні ? Чи буде виведено "Ви привітали Алісу"? Або "Ви привітали Боба"? Зробіть припущення на основі того, що ви знаєте, а потім спробуйте:

import { useState } from 'react';

export default function Form() {
  const [to, setTo] = useState('Alice');
  const [message, setMessage] = useState('Hello');

  function handleSubmit(e) {
    e.preventDefault();
    setTimeout(() => {
      alert(`You said ${message} to ${to}`);
    }, 5000);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        To:{' '}
        <select
          value={to}
          onChange={e => setTo(e.target.value)}>
          <option value="Alice">Alice</option>
          <option value="Bob">Bob</option>
        </select>
      </label>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}
label, textarea { margin-bottom: 10px; display: block; }

React зберігає значення стану "фіксованими" в обробниках подій одного рендеру. Вам не потрібно турбуватися, чи змінився стан під час роботи коду.

А якщо ви хочете прочитати останній стан перед повторним рендерингом? Вам слід скористатися функцією оновлення стану, яку описано на наступній сторінці!

  • Стан налаштування запитує новий рендеринг.
  • React зберігає стан поза вашим компонентом, ніби на полиці.
  • Коли ви викликаєте useState, React надає вам знімок стану для цього рендерингу.
  • Змінні та обробники подій не "переживають" повторний рендер. Кожен рендер має власні обробники подій.
  • Кожен рендер (і функції всередині нього) завжди "бачитимуть" знімок стану, який React надав тому рендеру.
  • Ви можете подумки підставляти стан в обробниках подій, подібно до того, як ви думаєте про відображений JSX.
  • Обробники подій, створені у минулому, мають значення стану з рендерингу, в якому їх було створено.

Реалізувати світлофор

Це компонент освітлення пішохідного переходу, який перемикається при натисканні кнопки:

import { useState } from 'react';

export default function TrafficLight() {
  const [walk, setWalk] = useState(true);

  function handleClick() {
    setWalk(!walk);
  }

  return (
    <>
      <button onClick={handleClick}>
        Change to {walk ? 'Stop' : 'Walk'}
      </button>
      <h1 style={{
        color: walk ? 'darkgreen' : 'darkred'
      }}>
        {walk ? 'Walk' : 'Stop'}
      </h1>
    </>
  );
}
h1 { margin-top: 20px; }

Додайте повідомлення alert до обробника кліку. Коли індикатор зелений і вказує на "Walk", натискання кнопки повинно означати "Stop is next" (наступна зупинка). Коли індикатор червоного кольору з написом "Стоп", натискання кнопки має означати "Далі - прогулянка".

Чи є різниця, чи ставити попередження до чи після виклику setWalk?

Ваше попередження має виглядати так:

import { useState } from 'react';

export default function TrafficLight() {
  const [walk, setWalk] = useState(true);

  function handleClick() {
    setWalk(!walk);
    alert(walk ? 'Stop is next' : 'Walk is next');
  }

  return (
    <>
      <button onClick={handleClick}>
        Change to {walk ? 'Stop' : 'Walk'}
      </button>
      <h1 style={{
        color: walk ? 'darkgreen' : 'darkred'
      }}>
        {walk ? 'Walk' : 'Stop'}
      </h1>
    </>
  );
}
h1 { margin-top: 20px; }

Не має значення, чи ви додасте його до або після виклику setWalk. У цьому рендерингу значення walk зафіксовано. Виклик setWalk змінить його лише для наступного рендерингу, але не вплине на обробник подій попереднього рендерингу.

Цей рядок може спочатку здатися неінтуїтивним:

alert(walk ? 'Stop is next' : 'Walk is next');

Але це має сенс, якщо ви прочитаєте це як: "Якщо світлофор показує "Йди", повідомлення має бути "Наступною буде зупинка"." Змінна walk у вашому обробнику подій відповідає значенню walk і не змінюється.

Ви можете перевірити, чи це правильно, застосувавши метод підстановки. Коли walk дорівнює правда, ви отримаєте:

<button onClick={() => {
  setWalk(false);
  alert('Stop is next');
}}>
  Change to Stop
</button>
<h1 style={{color: 'darkgreen'}}>
  Walk
</h1>

Таким чином, натискання кнопки "Змінити на Stop" ставить у чергу рендеринг з walk, встановленим у false, і сповіщає "Stop is next".