Раздел 7 · Браузер и Frontend

React: хуки, useId и работа с DOM через ref

middle~70 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

React: хуки, useId и работа с DOM через ref

В уроке 05 я обещал, что код встраивается в моменты mount, update и unmount через useEffect, но саму механику отложил. Пора открыть коробку. Хуки это то, что превращает чистую функцию-компонент в нечто живое: она помнит своё состояние между рендерами, реагирует на события внешнего мира, держит ссылку на узел DOM. В этом уроке мы разберём, как React хранит состояние, почему хуки нельзя звать под условием, как правильно синхронизироваться с внешним миром через эффекты и не попасть в самую частую ловушку React (устаревшее замыкание). И в конце Echo наконец начнёт грузить сообщения с сервера.

1. Правила хуков и почему они есть

Тело компонента вызывается заново на каждом рендере (это мы поняли в уроке 05). Тогда вопрос: как useState помнит значение между вызовами, если переменные внутри функции живут только один вызов?

Ответ простой и немного пугающий. React хранит состояние не в твоих переменных, а в своей внутренней структуре, привязанной к этому экземпляру компонента. Хуки в ней лежат по порядку вызова: первый useState это ячейка 0, второй useState ячейка 1, useEffect ниже ячейка 2, и так далее. На следующем рендере React идёт по тому же списку в том же порядке и отдаёт каждому хуку его ячейку.

рендер 1                  рендер 2 (всё совпало)
ячейка 0  useState(name)  ячейка 0  useState(name)   та же память
ячейка 1  useState(url)   ячейка 1  useState(url)    та же память
ячейка 2  useEffect(...)  ячейка 2  useEffect(...)    та же память

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

// БАГ: хук под условием
function Profile({ showAvatar }: { showAvatar: boolean }) {
  const [name, setName] = useState('Вася'); // ячейка 0
  if (showAvatar) {
    const [url, setUrl] = useState(''); // ячейка 1, только иногда
  }
  useEffect(() => {
    /* ... */
  }); // ячейка 2 или 1, как повезёт
}

Когда showAvatar станет false, хук с url пропадёт, useEffect сдвинется на ячейку 1 и начнёт читать состояние, которое принадлежало url. Поэтому два правила хуков:

  • хуки зовём только на верхнем уровне компонента или другого хука, не внутри if, циклов, вложенных функций;
  • хуки зовём только из React-компонентов и из своих хуков, не из обычных функций.

За соблюдением следит плагин eslint-plugin-react-hooks, он подсветит нарушение прямо в редакторе. Если тебе нужно условие, ставь его внутри хука, а не вокруг: useEffect(() => { if (showAvatar) {...} }, [...]).

2. useState и useReducer

С useState ты знаком с урока 04. Здесь добавлю две детали, без которых он кусается.

Первая: state это снимок на время рендера. setText('новое') не меняет переменную text прямо сейчас, а планирует новый рендер, где text будет уже другим. В текущем рендере text останется прежним до конца функции.

Вторая: когда новое значение зависит от старого, используй функциональную форму. Сравни:

setCount(count + 1); // читает count из снимка этого рендера
setCount((c) => c + 1); // получает свежее значение от React

Разница неважна, пока ты вызываешь setCount один раз. Но как только обновлений несколько подряд или вызов живёт внутри таймера или подписки, функциональная форма спасает от устаревшего значения. К этому вернёмся в разделе 4, это сердце самого частого бага React.

Когда переходов состояния много и они связаны (загрузка, успех, ошибка, повтор), отдельные useState начинают противоречить друг другу: можно случайно выставить isLoading и error одновременно. Тогда берут useReducer:

type State = { count: number; step: number };
type Action = { type: 'inc' } | { type: 'dec' } | { type: 'setStep'; step: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'inc':
      return { ...state, count: state.count + state.step };
    case 'dec':
      return { ...state, count: state.count - state.step };
    case 'setStep':
      return { ...state, step: action.step };
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
  return (
    <button type="button" onClick={() => dispatch({ type: 'inc' })}>
      {state.count}
    </button>
  );
}

reducer это чистая функция из урока 05: те же state и action дают тот же результат, никаких запросов и мутаций внутри. Это мини-Redux в одном компоненте, и это мост к слою стейт-менеджмента: в уроке 10 ровно тот же reducer переедет в RTK slice почти без изменений. Эвристика выбора: один независимый переключатель это useState, связанная группа переходов это useReducer.

3. useEffect: синхронизация с внешним миром

Тело компонента должно быть чистым: посчитать JSX и ничего больше. Но приложению надо ходить в сеть, подписываться на события, ставить таймеры. Этим занимается useEffect. Думай о нём не как о lifecycle-методе, а как о синхронизации: эффект держит компонент в согласии с чем-то снаружи.

У эффекта два слагаемых: функция и массив зависимостей.

useEffect(() => {
  // эффект: выполнится после рендера
  return () => {
    // cleanup: выполнится перед следующим эффектом и при unmount
  };
}, [dep1, dep2]); // зависимости

Массив зависимостей это договор: “перезапусти эффект, только если что-то из этого изменилось”. Три варианта:

  • [] пустой массив: эффект один раз после монтирования, cleanup при размонтировании. Сюда кладут подписку на глобальное событие.
  • [channelId]: эффект перезапускается каждый раз, когда меняется channelId. Сюда кладут загрузку данных по ключу.
  • без массива вовсе: эффект после каждого рендера. Почти всегда это ошибка, и ESLint попросит объяснений.

Cleanup-функция обязательна везде, где эффект что-то начал. Подписался на событие, верни отписку. Поставил таймер, верни его остановку. Начал запрос, верни его отмену:

useEffect(() => {
  function onResize() {
    /* ... */
  }
  window.addEventListener('resize', onResize);
  return () => window.removeEventListener('resize', onResize);
}, []);

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

// лишний эффект: fullName это просто вычисление
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(`${first} ${last}`);
}, [first, last]);

// правильно: посчитать при рендере
const fullName = `${first} ${last}`;

Эффект нужен только для общения с внешним миром, а не для пересчёта данных, которые и так выводятся из props и state. Подробный разбор в react.dev есть в гайде “You Might Not Need an Effect”, прочитай его, он снимает половину будущих вопросов.

4. Ловушка устаревшего замыкания

Это самый частый и самый коварный баг React. Разберём на таймере. Хотим счётчик, который тикает раз в секунду:

// БАГ: счётчик застревает на 1
function Ticker() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // count тут навсегда 0
    }, 1000);
    return () => clearInterval(id);
  }, []); // пустой массив: эффект только при монтировании

  return <p>{count}</p>;
}

Счётчик доходит до 1 и замирает. Почему: эффект с [] запустился один раз, при монтировании, когда count был 0. Функция внутри setInterval замкнула count равным 0 и навсегда видит ноль. Каждую секунду она вызывает setCount(0 + 1), то есть всегда выставляет 1.

Чинить можно двумя способами. Первый, чаще правильный: функциональный updater. Он не читает count из замыкания, а получает свежее значение от React:

setInterval(() => {
  setCount((c) => c + 1); // c всегда актуально
}, 1000);

Второй способ нужен, когда внутри таймера надо читать (а не только писать) свежее значение, например залогировать его. Тогда держат свежее значение в ref и читают через .current:

const countRef = useRef(count);
countRef.current = count; // обновляем на каждом рендере

useEffect(() => {
  const id = setInterval(() => {
    console.log('сейчас', countRef.current); // всегда свежее
  }, 1000);
  return () => clearInterval(id);
}, []);

Этот баг и оба способа починки твоё домашнее задание hw-fe-06-stale-closure. Запомни правило: если внутри долгоживущей функции (таймер, подписка, обработчик) ты читаешь state или props, спроси себя, не замкнул ли ты устаревший снимок. Механику таймеров и порядок их выполнения смотри в уроке про event loop, ссылка в конце.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.