К списку уроков

Урок 10. Состояние: useState, useEffect и иммутабельность

Онлайн и в Б-835, 29 октября (четверг), 15:00

Предисловие

Для вашего удобства текстовый курс перенесен на отдельную платформу. Ссылка для поступления: тык

Мы настоятельно рекомендуем пользоваться именно этой платформой по ходу курса. Ссылка на текущее занятие: тык

⚠️Прежде чем переходить к заданиям - обязательно зарегистрируйтесь на курс. Иначе вас закинет на общий поток и вы не сможете взаимодействовать с сверстниками во втором модуле курса. Это не смертельно и вполне решаемо, но тем не менее.

Состояние: useState, useEffect и иммутабельность

Рендер и коммит

Компонент — функция, и React вызывает её каждый раз, когда нужно узнать, как должен выглядеть интерфейс. Вызов компонента называется рендером.

Обновление интерфейса в React проходит в два этапа:

  1. Рендер. React вызывает компоненты и получает от них JSX — описание того, как должен выглядеть интерфейс сейчас.

  2. Коммит. React сравнивает новое описание с предыдущим и вносит в DOM только различия. Если после рендера текст кнопки поменялся, а всё остальное осталось прежним, React изменит только текст этой кнопки.

Из этого следует главное правило React-кода: компонент описывает результат, а не шаги. В обработчике не нужно искать кнопку и менять её текст. Достаточно, чтобы компонент при одних данных возвращал «В избранное», а при других «В избранном». Переход между этими состояниями выполнит React.

Рендер должен быть чистым. Одинаковые пропсы и состояние дают одинаковый JSX, а сам рендер ничего не меняет вокруг: не отправляет запросы, не меняет внешние переменные, не работает с DOM напрямую. React может вызвать компонент в любой момент и любое число раз, и чистый компонент от этого не сломается.

У компонента есть жизненный цикл из трёх стадий:

  • монтирование — компонент впервые появился на экране;

  • обновление — компонент перерисовался с новыми данными;

  • размонтирование — компонент исчез с экрана, например закрылось модальное окно.

Почитать ещё

Когда компонент перерисовывается

React перерисовывает компонент в трёх случаях.

  1. Первый показ. Компонент попал на экран и отрисовался в первый раз.

  2. Изменилось состояние компонента. Компонент вызвал функцию, которая меняет его состояние. Состояние разберём в следующих разделах.

  3. Перерисовался родитель. Когда React перерисовывает компонент, он перерисовывает и все компоненты внутри него, даже если их пропсы не изменились.

Других причин нет. Если компонент перерисовался, значит, изменилось его состояние или перерисовался кто-то выше по дереву.

Третий случай иногда удивляет: при вводе символа в поле поиска на доске перерисуется каждая карточка, хотя карточки не менялись. Обычно это незаметно, потому что рендер компонента выполняется быстро, а DOM React меняет только там, где есть различия. Как запретить лишние перерисовки там, где они мешают, разберём на четырнадцатом занятии.

Чтобы увидеть рендеры, можно временно вывести сообщение в консоль в начале компонента:

export function CardPreview({ card, onOpen }: Props) {
  console.log('Рендер карточки', card.title);
  return <button type="button" onClick={() => onOpen(card.id)}>{card.title}</button>;
}

Удобнее смотреть на компоненты в расширении браузера React Developer Tools. Оно добавляет в инструменты разработчика вкладку Components с деревом компонентов, их пропсами и состоянием, и вкладку Profiler, которая показывает, какие компоненты перерисовывались и почему.

Почитать ещё

Хуки и их правила

Функции, имена которых начинаются с use, называются хуками. Хуки дают компоненту возможности, которых нет у обычной функции: память между рендерами, работу с внешним миром, доступ к общим данным. На этом занятии мы разберём два главных хука: useState и useEffect.

import { useEffect, useState } from 'react';

export function BoardPage() {
  const [openCardId, setOpenCardId] = useState<string | null>(null);

  useEffect(() => {
    document.title = 'ITAM Board';
  }, []);

  // ...
}

У хуков два правила:

  • Хуки вызывают только на верхнем уровне компонента. Хук нельзя вызвать внутри условия, цикла или вложенной функции.

  • Хуки вызывают только из компонентов или из других хуков. Обычная функция вроде formatDateTime хуки вызывать не может.

React не знает имён ваших хуков. Он запоминает их по порядку вызова: первый useState компонента, второй useState, первый useEffect. Если в одном рендере хук вызвался, а в следующем из-за условия нет, порядок собьётся, и React перепутает, какое состояние какому хуку принадлежит.

// Неправильно: хук внутри условия
if (card.is_mine) {
  const [isEditing, setIsEditing] = useState(false);
}

// Правильно: хук всегда вызывается, а условие проверяется при использовании
const [isEditing, setIsEditing] = useState(false);

Если компонент ничего не должен показывать при каком-то условии, ранний return null ставят после всех вызовов хуков.

Почитать ещё

useState

Обычные переменные внутри компонента не подходят для данных, которые меняются. При каждом рендере функция компонента выполняется заново, и переменные создаются с начальными значениями. Кроме того, изменение обычной переменной не заставляет React перерисовать компонент.

Хук useState даёт компоненту память, которая сохраняется между рендерами.

import { useState } from 'react';

export function LikeButton() {
  const [liked, setLiked] = useState(false);

  return (
    <button type="button" onClick={() => setLiked(!liked)}>
      {liked ? '♥ В избранном' : '♡ В избранное'}
    </button>
  );
}
  • useState(false) создаёт состояние с начальным значением false.

  • Хук возвращает массив из двух элементов: текущее значение и функцию, которая его меняет. Их сразу деструктурируют и называют по схеме [что-то, setЧто-то].

  • Вызов setLiked(новоеЗначение) сохраняет новое значение и просит React перерисовать компонент. В новом рендере liked будет равен новому значению.

Тип состояния TypeScript выводит из начального значения. Если начальное значение null, а позже там будет строка, тип указывают явно:

const [openCardId, setOpenCardId] = useState<string | null>(null);

У каждого экземпляра компонента своё состояние. Если на странице десять кнопок LikeButton, у каждой будет своё значение liked. Когда компонент размонтируется, его состояние пропадает, а при следующем монтировании начнётся с начального значения.

Начальное значение используется только при первом рендере. Если его вычисление требует времени, передают функцию: useState(() => loadFromStorage()). React вызовет её один раз.

Почитать ещё

Состояние как снимок

Функция изменения состояния не меняет переменную в текущем рендере. Она запоминает новое значение и планирует следующий рендер.

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

function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  console.log(count);
}

После нажатия count станет равен 1, а не 2, и в консоль выведется 0. В этом рендере count равен 0, поэтому оба вызова просят записать 0 + 1. Переменная count — снимок значения на момент рендера, и в пределах одного рендера она не меняется.

Если новое значение зависит от предыдущего, в функцию изменения передают функцию. React вызовет её с актуальным значением:

function handleClick() {
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
}

Теперь после нажатия count станет равен 2. Так же в ITAM Board добавляют комментарий в список: setComments((prev) => [...prev, comment]). Запись с функцией особенно важна в асинхронном коде: пока ждали ответ сервера, состояние могло измениться, и значение из снимка устарело бы.

React объединяет несколько изменений состояния в одном обработчике в один рендер. Если обработчик вызвал setTitle, setType и setIsOpen, компонент перерисуется один раз, уже со всеми тремя новыми значениями.

Почитать ещё

Что хранить в состоянии

Состояние стоит делать минимальным: хранить только то, что нельзя вычислить из других данных.

Не храните то, что можно вычислить. Число карточек в колонке, отфильтрованный список, текст «Карточек: 5» вычисляют во время рендера:

// Лишнее состояние: его нужно не забыть обновить при каждом изменении cards
const [cardsCount, setCardsCount] = useState(cards.length);

// Значение вычисляется при каждом рендере и всегда совпадает с данными
const cardsCount = cards.length;

Не храните одно и то же в двух местах. На доске модальное окно открыто, когда выбрана карточка. Можно было бы завести два состояния, isModalOpen и openCardId, но тогда появится возможность ошибиться: окно открыто, а карточка не выбрана. Одного состояния достаточно:

const [openCardId, setOpenCardId] = useState<string | null>(null);

return (
  <>
    <BoardColumns cards={cards} onOpenCard={setOpenCardId} />
    {openCardId && <CardModal cardId={openCardId} onClose={() => setOpenCardId(null)} />}
  </>
);

Окно открыто, если openCardId не null. Обратите внимание на onOpenCard={setOpenCardId}: функция изменения состояния сама подходит как колбэк, потому что принимает id карточки.

Не копируйте пропсы в состояние без причины. Запись useState(props.card) сохранит карточку на момент первого рендера. Если родитель передаст обновлённую карточку, состояние об этом не узнает. Используйте проп напрямую. Копировать проп в состояние имеет смысл, только когда это начальное значение, которое компонент дальше меняет сам, например значение поля формы при редактировании.

Храните идентификатор, а не копию объекта. openCardId лучше, чем openCard: если карточка изменится, модальное окно найдёт её актуальную версию по id.

Почитать ещё

Подъём состояния

Если одно и то же состояние нужно нескольким компонентам, его хранят в их ближайшем общем родителе и передают вниз через пропсы. Перенос состояния из дочернего компонента в родителя называют подъёмом состояния.

На доске ITAM Board карточку открывает CardPreview, а показывает CardModal. Ни один из них не может хранить openCardId у себя, потому что другой не узнает об изменении. Их общий родитель — страница доски, и состояние живёт в ней:

BoardPage              хранит openCardId
├── BoardColumns       получает onOpenCard и передаёт его дальше
│   └── CardPreview    вызывает onOpen(card.id)
└── CardModal          получает cardId и onClose
export function BoardPage() {
  const [openCardId, setOpenCardId] = useState<string | null>(null);
  const [isCreating, setIsCreating] = useState(false);

  return (
    <>
      <Button variant="primary" onClick={() => setIsCreating(true)}>
        + Новая карточка
      </Button>

      <BoardColumns cards={mockCards} onOpenCard={setOpenCardId} />

      {openCardId && <CardModal cardId={openCardId} onClose={() => setOpenCardId(null)} />}

      {isCreating && (
        <Modal title="Новая карточка" onClose={() => setIsCreating(false)}>
          <p>Здесь будет форма создания карточки.</p>
        </Modal>
      )}
    </>
  );
}

CardPreview и CardModal не хранят информацию о том, открыта ли карточка. Первый сообщает о нажатии, второй сообщает о закрытии, а решение принимает страница.

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

Почитать ещё

Модальное окно

Модальное окно — частый элемент интерфейса: окно поверх страницы, фон за которым затемнён. В React модальное окно показывают условным рендером: пока состояние говорит, что окно открыто, компонент окна есть в разметке.

import type { MouseEvent, ReactNode } from 'react';
import styles from './Modal.module.css';

type Props = {
  title: string;
  onClose: () => void;
  children: ReactNode;
};

export function Modal({ title, onClose, children }: Props) {
  function handleBackdropMouseDown(event: MouseEvent<HTMLDivElement>) {
    if (event.target === event.currentTarget) {
      onClose();
    }
  }

  return (
    <div className={styles.backdrop} onMouseDown={handleBackdropMouseDown}>
      <div className={styles.window} role="dialog" aria-modal="true" aria-label={title}>
        <header className={styles.header}>
          <h2 className={styles.title}>{title}</h2>
          <button type="button" className={styles.close} onClick={onClose} aria-label="Закрыть">
            ×
          </button>
        </header>
        {children}
      </div>
    </div>
  );
}
/* Modal.module.css */
.backdrop {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 16px;
  background: rgb(0 0 0 / 0.5);
}

.window {
  width: min(560px, 100%);
  max-height: 90vh;
  overflow: auto;
  padding: 20px;
  border-radius: 16px;
  background: white;
}
  • Затемнение .backdrop занимает весь экран: position: fixed и inset: 0 растягивают его от края до края окна браузера.

  • Нажатие на затемнение закрывает окно. Проверка event.target === event.currentTarget отличает нажатие на само затемнение от нажатия внутри окна: событие из окна всплывает к затемнению, но его target — элемент внутри окна.

  • Обработчик назначен на onMouseDown, а не на onClick. Если пользователь начал выделять текст в окне и отпустил кнопку мыши над затемнением, click произошёл бы на затемнении, и окно закрылось бы случайно.

  • Атрибуты role="dialog" и aria-modal="true" сообщают программам экранного доступа, что это диалоговое окно.

  • Модальное окно не решает, когда закрываться: оно вызывает onClose, а состояние меняет родитель.

Модальное окно должно закрываться и клавишей Escape. Для этого нужно подписаться на событие клавиатуры всего окна браузера, а это уже работа с внешним миром. Её разберём в следующем разделе.

Почитать ещё

useEffect

Рендер должен быть чистым, но компонентам нужно работать с тем, что находится за пределами React: подписываться на события окна браузера, запускать таймеры, загружать данные, менять заголовок вкладки. Такие действия называют побочными эффектами. Для них есть хук useEffect.

import { useEffect, type MouseEvent, type ReactNode } from 'react';

export function Modal({ title, onClose, children }: Props) {
  useEffect(() => {
    function handleKeyDown(event: KeyboardEvent) {
      if (event.key === 'Escape') {
        onClose();
      }
    }

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [onClose]);

  // ...разметка модального окна
}

useEffect принимает два аргумента:

  1. Функцию эффекта. React выполнит её после того, как компонент отрисовался и изменения попали на экран. Здесь эффект подписывается на нажатия клавиш.

  2. Массив зависимостей. Эффект выполнится заново, только если изменилось одно из значений в массиве. Здесь это onClose.

Функция, которую вернул эффект, называется очисткой. React вызывает её перед повторным запуском эффекта и при размонтировании компонента. Здесь очистка отписывается от нажатий клавиш. Без очистки каждое открытие модального окна добавляло бы новый обработчик, и после нескольких открытий и закрытий Escape вызывал бы onClose у давно закрытых окон.

В обработчике события окна тип KeyboardEvent — встроенный тип браузера, а не тип из React. События, которые назначают через атрибуты JSX, используют типы React, а события, на которые подписываются через addEventListener, — типы браузера.

Эффект читается как предложение: «пока модальное окно на экране, слушай нажатия клавиш и закрывай окно по Escape». Если эффект не получается описать таким предложением, скорее всего, он спроектирован неудачно.

Почитать ещё

Зависимости эффекта

Массив зависимостей определяет, когда эффект запускается заново.

Второй аргумент

Когда выполняется эффект

Нет массива

После каждого рендера

[]

Один раз после монтирования

[cardId]

После монтирования и после каждого рендера, в котором изменился cardId

Перед каждым повторным запуском React вызывает очистку предыдущего запуска, а при размонтировании вызывает очистку последнего.

В массив зависимостей записывают все значения из компонента, которые использует эффект: пропсы, состояние и функции, объявленные внутри компонента. Значения, объявленные вне компонента, например импортированные функции и константы, указывать не нужно.

export function DocumentTitle({ cards }: { cards: Card[] }) {
  useEffect(() => {
    document.title = `ITAM Board · карточек: ${cards.length}`;
  }, [cards]);

  return null;
}

Эффект использует cards, поэтому cards есть в зависимостях. Если убрать массив, заголовок будет обновляться после каждого рендера, даже когда карточки не менялись. Если оставить пустой массив, заголовок установится один раз и не обновится, когда карточек станет больше.

Не убирайте значения из массива зависимостей, чтобы эффект запускался реже. Эффект начнёт работать с устаревшими значениями из старого рендера, и такую ошибку трудно найти. Если эффект запускается слишком часто, нужно менять сам эффект или причину, по которой меняется зависимость.

Сравнение зависимостей идёт по ссылке, как у ===. Объект или функция, созданные заново при рендере, считаются новым значением, даже если содержимое не изменилось. Как с этим работать, разберём на четырнадцатом занятии.

Почитать ещё

StrictMode и двойной запуск

В файле main.tsx приложение обёрнуто в <StrictMode>. В режиме разработки этот компонент намеренно делает две вещи, чтобы ошибки проявились у разработчика раньше, чем у пользователя:

  • вызывает функции компонентов дважды при каждом рендере. Если компонент нечистый, например меняет внешнюю переменную, двойной вызов это покажет;

  • после первого монтирования сразу размонтирует компонент и монтирует снова. Эффект выполнится, затем выполнится его очистка, затем эффект выполнится ещё раз.

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

useEffect(() => {
  console.log('Подписались');
  window.addEventListener('keydown', handleKeyDown);
  return () => {
    console.log('Отписались');
    window.removeEventListener('keydown', handleKeyDown);
  };
}, []);

При открытии модального окна в консоли появятся три строки: «Подписались», «Отписались», «Подписались». Если у эффекта правильная очистка, после этого останется ровно одна подписка, и приложение работает так же, как без StrictMode.

Двойной запуск проверяет, что эффект можно безопасно остановить и запустить заново. Если без очистки подписка появится дважды, Escape закроет окно два раза. Если эффект отправляет запрос, ответ первого запуска нужно уметь проигнорировать. Как это сделать, разберём на двенадцатом занятии.

Не отключайте StrictMode, чтобы избавиться от двойных сообщений. Лучше исправить эффект, у которого нет очистки.

Почитать ещё

  • React: StrictMode

Когда эффект не нужен

Эффекты нужны для работы с внешним миром. Частая ошибка — использовать их для того, что можно сделать обычным кодом.

Вычисления из пропсов и состояния. Отфильтрованный список не нужно хранить в состоянии и обновлять эффектом:

// Лишний эффект: сначала рендер со старым списком, потом ещё один с новым
const [visibleCards, setVisibleCards] = useState<Card[]>([]);
useEffect(() => {
  setVisibleCards(cards.filter((card) => card.title.includes(search)));
}, [cards, search]);

// Достаточно вычислить во время рендера
const visibleCards = cards.filter((card) => card.title.includes(search));

Реакция на действия пользователя. Если что-то должно произойти после нажатия кнопки, это делают в обработчике нажатия. Не нужно заводить состояние «кнопку нажали» и эффект, который за ним следит:

// Лишний эффект
const [voted, setVoted] = useState(false);
useEffect(() => {
  if (voted) {
    console.log('Проголосовали');
  }
}, [voted]);

// Действие прямо в обработчике
function handleVote() {
  console.log('Проголосовали');
}

Эффект, который меняет свою зависимость. Если эффект без массива зависимостей или с зависимостью меняет состояние, от которого сам зависит, получится бесконечный цикл рендеров. Если в таком эффекте отправляется запрос, вкладка Network заполнится одинаковыми запросами, а сервер начнёт отвечать ошибкой 429 Too Many Requests.

Перед тем как написать эффект, спросите себя: с какой внешней системой он синхронизирует компонент? Если ответа нет, эффект, скорее всего, не нужен.

Почитать ещё

Иммутабельность в React

Когда вызывают функцию изменения состояния, React сравнивает новое значение со старым через Object.is, то есть по ссылке для объектов и массивов. Если ссылка не изменилась, React считает, что состояние осталось прежним, и может не перерисовать компонент.

const [cards, setCards] = useState(mockCards);

// Не сработает: массив тот же самый, изменилось только его содержимое
function addCardWrong(card: Card) {
  cards.push(card);
  setCards(cards);
}

// Сработает: создан новый массив
function addCard(card: Card) {
  setCards((prev) => [card, ...prev]);
}

Поэтому состояние в React никогда не меняют на месте: объекты и массивы в состоянии считают доступными только для чтения. Чтобы изменить данные, создают новый объект или массив с нужными изменениями. Этот приём мы использовали с пятого занятия, а теперь он стал обязательным.

Изменение на месте ломает не только перерисовку:

  • эффекты не перезапускаются, потому что их зависимости сравниваются по ссылке;

  • оптимизации, которые пропускают рендер неизменённых компонентов, перестают работать;

  • значение, сохранённое раньше, неожиданно меняется, и ошибку трудно найти.

То же правило действует для пропсов. Компонент VoteButtons не должен менять card.score у карточки, которую получил в пропсах. Если нужно показать другой счёт, его вычисляют во время рендера или хранят в собственном состоянии компонента.

Почитать ещё

Обновление массивов и объектов в состоянии

Для типичных изменений состояния есть привычные записи. В каждой из них создаётся новый массив или объект, а старый не меняется.

// Добавить элемент в конец и в начало
setComments((prev) => [...prev, comment]);
setCards((prev) => [card, ...prev]);

// Удалить элемент
setComments((prev) => prev.filter((comment) => comment.id !== commentId));

// Заменить один элемент
setCards((prev) => prev.map((card) => (card.id === updated.id ? updated : card)));

// Изменить поле одного элемента
setCards((prev) =>
  prev.map((card) => (card.id === cardId ? { ...card, comments_count: card.comments_count + 1 } : card)),
);

// Изменить поле объекта
setFilters((prev) => ({ ...prev, search: value }));

// Изменить вложенное поле
setProfile((prev) => ({ ...prev, stream: { ...prev.stream, title } }));

При замене одного элемента через map остальные элементы остаются теми же самыми объектами. Это важно для оптимизаций: React сможет понять, что карточки, которые не менялись, перерисовывать не нужно.

Методы массивов, которые меняют исходный массив: push, pop, shift, unshift, splice, sort, reverse. С состоянием их не используют.

Методы, которые возвращают новый массив: map, filter, slice, concat, toSorted, toReversed, toSpliced, with. Для сортировки состояния используют toSorted.

Если изменение вложенных объектов через spread становится длинным и трудно читается, это признак того, что структуру состояния стоит упростить, например хранить элементы плоским списком с идентификаторами.

Почитать ещё

Задания