Урок 29 из 49 · Месяц 4. React

Состояние и жизненный цикл: useState и useEffect

Содержание урока

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

Зачем нужно состояние

Попробуем сделать счётчик на обычной переменной:

function Counter() {
  let count = 0;

  function handleClick() {
    count = count + 1;
    console.log(count); // 1, 2, 3...
  }

  return <button onClick={handleClick}>Нажато: {count}</button>;
}

В консоли число растёт, а на кнопке навсегда «0». Почему?

  1. React не знает, что переменная изменилась, и не перерисовывает компонент.
  2. Даже если бы перерисовал — функция Counter вызвалась бы заново, и let count = 0 снова обнулил бы значение.

Нужна переменная, которая сохраняется между рендерами и при изменении заставляет React перерисовать компонент. Это и есть состояние (state).

useState

Хук — специальная функция React, название которой начинается с use. Хуки дают компонентам дополнительные возможности. Первый и главный — useState.

import { useState } from 'react';

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

  function handleClick() {
    setCount(count + 1);
  }

  return <button onClick={handleClick}>Нажато: {count}</button>;
}

export default Counter;

Разбор по частям:

  • import { useState } from 'react' — хук нужно импортировать.
  • useState(0) — создаём состояние с начальным значением 0.
  • useState возвращает массив из двух элементов, мы сразу деструктурируем его:
    • count — текущее значение;
    • setCount — функция, которая меняет значение и запускает перерисовку.
  • setCount(count + 1) — просим React: «запомни новое значение и перерисуй компонент».

Что происходит по шагам при клике:

1. Первый рендер: count = 0 → на кнопке «Нажато: 0»
2. Клик → setCount(1)
3. React вызывает Counter() снова, useState возвращает уже 1
4. На кнопке «Нажато: 1»

Аналогия: состояние — это блокнот, который React хранит за компонент. Функция-компонент каждый раз запускается с чистого листа, но может заглянуть в блокнот и узнать, на чём остановилась.

Несколько состояний

В компоненте может быть сколько угодно useState, любых типов:

function Profile() {
  const [name, setName] = useState('');          // строка
  const [age, setAge] = useState(18);            // число
  const [isAdmin, setIsAdmin] = useState(false); // логическое

  return (
    <div>
      <p>{name || 'Без имени'}, {age} лет {isAdmin && '(админ)'}</p>
      <button onClick={() => setAge(age + 1)}>Стать старше</button>
      <button onClick={() => setIsAdmin(!isAdmin)}>Переключить роль</button>
      <button onClick={() => setName('Айгерим')}>Задать имя</button>
    </div>
  );
}

setIsAdmin(!isAdmin) — классический переключатель (toggle): было true — станет false, и наоборот. Так делают «показать/скрыть пароль», открытие меню, тёмную тему.

Управляемое поле ввода

Чтобы React знал, что введено в <input>, связываем поле с состоянием:

function NameInput() {
  const [name, setName] = useState('');

  return (
    <div>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Ваше имя"
      />
      <p>Привет, {name || 'незнакомец'}!</p>
      <p>Символов: {name.length}</p>
      <button onClick={() => setName('')}>Очистить</button>
    </div>
  );
}

Это называется управляемый компонент (controlled): значение поля всегда равно состоянию (value={name}), а каждое нажатие клавиши обновляет состояние через onChange. Кнопка «Очистить» очищает поле — потому что поле показывает то, что в состоянии.

Обновление на основе предыдущего значения

Посмотрите на код и угадайте, что будет после клика:

function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

Ожидаете +3, а получите +1. Состояние внутри одного рендера — это снимок: count всё время равен, скажем, 0, и все три вызова говорят «сделай 1». Если новое значение зависит от старого, передайте в сеттер функцию:

function handleClick() {
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1); // теперь +3
}

React поставит функции в очередь и каждой передаст свежее значение prev. Хорошая привычка — использовать такую форму всегда, когда новое значение считается из старого.

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

Главное правило: состояние не мутируют (не изменяют на месте), а заменяют новым значением. React сравнивает старое и новое значения по ссылке: если вы изменили поле внутри того же объекта, для React это «тот же объект» — перерисовки не будет.

const [user, setUser] = useState({ name: 'Айгерим', city: 'Алматы' });

// ❌ мутация — React не заметит
user.city = 'Астана';
setUser(user);

// ✅ новый объект: копируем старые поля через spread и меняем нужное
setUser({ ...user, city: 'Астана' });

С массивами — то же самое. Вместо push, splice, присваивания по индексу используем методы, которые возвращают новый массив:

Действие ❌ Мутирует ✅ Новый массив
Добавить arr.push(item) [...arr, item]
Удалить arr.splice(i, 1) arr.filter((x) => x.id !== id)
Изменить arr[i].done = true arr.map((x) => x.id === id ? { ...x, done: true } : x)
Отсортировать arr.sort() [...arr].sort() или arr.toSorted()

Соберём всё в мини-список покупок:

import { useState } from 'react';

function ShoppingList() {
  const [items, setItems] = useState([
    { id: 1, title: 'Молоко', bought: false },
    { id: 2, title: 'Хлеб', bought: true },
  ]);
  const [text, setText] = useState('');

  function handleAdd(e) {
    e.preventDefault();
    if (!text.trim()) return; // пустое не добавляем
    const newItem = { id: Date.now(), title: text.trim(), bought: false };
    setItems((prev) => [...prev, newItem]);
    setText('');
  }

  function handleToggle(id) {
    setItems((prev) =>
      prev.map((item) => (item.id === id ? { ...item, bought: !item.bought } : item))
    );
  }

  function handleDelete(id) {
    setItems((prev) => prev.filter((item) => item.id !== id));
  }

  return (
    <div>
      <form onSubmit={handleAdd}>
        <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Что купить?" />
        <button type="submit">Добавить</button>
      </form>

      <ul>
        {items.map((item) => (
          <li key={item.id}>
            <input type="checkbox" checked={item.bought} onChange={() => handleToggle(item.id)} />
            <span style={{ textDecoration: item.bought ? 'line-through' : 'none' }}>{item.title}</span>
            <button onClick={() => handleDelete(item.id)}>✕</button>
          </li>
        ))}
      </ul>

      <p>Куплено: {items.filter((i) => i.bought).length} из {items.length}</p>
    </div>
  );
}

export default ShoppingList;

Обратите внимание: «Куплено N из M» мы не храним в отдельном состоянии, а вычисляем из items при каждом рендере. Правило: если значение можно посчитать из уже существующего состояния или пропсов — не заводите для него ещё один useState.

Жизненный цикл компонента

У каждого компонента есть «жизнь» из трёх этапов:

  1. Монтирование (mount) — компонент впервые появился на странице.
  2. Обновление (update) — компонент перерисовался, потому что изменились состояние или пропсы.
  3. Размонтирование (unmount) — компонент убрали со страницы (например, закрыли модалку).

Иногда нужно сделать что-то вне рендера в один из этих моментов: запросить данные у сервера, запустить таймер, изменить document.title, подписаться на изменение размера окна. Такие действия называются побочными эффектами (side effects), и для них есть хук useEffect.

useEffect

import { useEffect } from 'react';

useEffect(() => {
  // код эффекта
  return () => {
    // необязательная функция очистки
  };
}, [зависимости]);
  • Первый аргумент — функция, которую React вызовет после того, как отрисует компонент на экране.
  • Второй аргумент — массив зависимостей: от него зависит, когда эффект будет запускаться заново.
  • Функция, которую возвращает эффект, — очистка: React вызовет её перед повторным запуском эффекта и при размонтировании.

Три варианта массива зависимостей

// 1. Без массива — после КАЖДОГО рендера (нужно редко)
useEffect(() => {
  console.log('Компонент отрисовался');
});

// 2. Пустой массив — один раз, после первого рендера (монтирование)
useEffect(() => {
  console.log('Компонент появился на странице');
}, []);

// 3. С зависимостями — при монтировании и каждый раз, когда изменилась любая из них
useEffect(() => {
  document.title = `Нажато ${count} раз`;
}, [count]);
Массив Когда срабатывает Пример использования
нет После каждого рендера Отладка
[] Один раз при появлении Загрузка данных, подписка на события
[a, b] При появлении и при изменении a или b Поиск при смене запроса

Очистка эффекта: таймер

import { useEffect, useState } from 'react';

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds((prev) => prev + 1);
    }, 1000);

    return () => {
      clearInterval(id); // очистка: останавливаем таймер
    };
  }, []);

  return <p>Прошло секунд: {seconds}</p>;
}

function App() {
  const [show, setShow] = useState(true);

  return (
    <div>
      <button onClick={() => setShow(!show)}>{show ? 'Скрыть' : 'Показать'} таймер</button>
      {show && <Timer />}
    </div>
  );
}

Разбор:

  • При появлении Timer эффект запускает setInterval.
  • setSeconds((prev) => prev + 1) — функциональная форма. Если написать setSeconds(seconds + 1), внутри интервала навсегда «застрял» бы seconds из первого рендера (0), и счётчик дошёл бы только до 1.
  • Когда мы нажимаем «Скрыть», компонент размонтируется, React вызывает очистку, и clearInterval останавливает таймер. Без очистки таймер продолжал бы работать в фоне — это утечка памяти. То же правило для addEventListener: подписались в эффекте — отпишитесь в очистке (removeEventListener).

Загрузка данных с сервера

Самый частый эффект — получить данные при открытии страницы. Используем fetch и async/await из урока fetch и async/await:

import { useEffect, useState } from 'react';

function UsersList() {
  const [users, setUsers] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false; // флаг: компонент ещё нужен?

    async function loadUsers() {
      try {
        const response = await fetch('https://jsonplaceholder.typicode.com/users');
        if (!response.ok) throw new Error(`Ошибка ${response.status}`);
        const data = await response.json();
        if (!ignore) setUsers(data);
      } catch (err) {
        if (!ignore) setError(err.message);
      } finally {
        if (!ignore) setIsLoading(false);
      }
    }

    loadUsers();

    return () => {
      ignore = true;
    };
  }, []);

  if (isLoading) return <p>Загрузка...</p>;
  if (error) return <p style={{ color: 'red' }}>Не удалось загрузить: {error}</p>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          {user.name} — {user.email}
        </li>
      ))}
    </ul>
  );
}

export default UsersList;

Разбор:

  • Три состояния: данные, флаг загрузки и ошибка. Интерфейс показывает разное в каждом случае.
  • Функцию эффекта нельзя сделать async напрямую (она должна возвращать функцию очистки, а не Promise). Поэтому внутри объявляем async function loadUsers() и сразу вызываем её.
  • ignore защищает от ситуации, когда компонент убрали со страницы, а ответ пришёл позже: мы не обновляем состояние «мёртвого» компонента.
  • Пустой массив [] — загружаем один раз при появлении.

Типичные ошибки

  • Бесконечный цикл. Эффект без массива зависимостей меняет состояние → рендер → эффект → снова меняет состояние... Всегда думайте о втором аргументе.
  • Объект или массив в зависимостях, который создаётся заново на каждом рендере, — эффект будет срабатывать постоянно.
  • Мутация состояния (push, изменение поля объекта) — интерфейс не обновляется.
  • Ожидание, что состояние изменится сразу: после setCount(5) строка console.log(count) выведет старое значение. Новое будет в следующем рендере.
  • Эффект для того, что можно вычислить. Не нужно useEffect, чтобы посчитать fullName из firstName и lastName — посчитайте прямо в теле компонента.
  • Забыли очистку у таймеров и подписок.

Практика

  1. Счётчик. Кнопки «−», «+» и «Сбросить». Счётчик не должен уходить ниже нуля. Ожидаемый результат: «−» на нуле ничего не делает.
  2. Показать пароль. Поле пароля с кнопкой-глазом, которая переключает type между password и text. Ниже — подсказка «Пароль слишком короткий», пока длина меньше 8.
  3. Список дел в памяти. Повторите ShoppingList с нуля, добавьте кнопку «Удалить купленные» и счётчик «Осталось купить: N».
  4. Заголовок вкладки. Сделайте так, чтобы document.title показывал «(N) Список покупок», где N — число некупленных товаров. Используйте useEffect с правильными зависимостями.
  5. Галерея с сервера. Загрузите https://jsonplaceholder.typicode.com/photos?_limit=12 и выведите сеткой. Покажите «Загрузка...» и сообщение об ошибке (проверьте, сломав адрес). Добавьте select с выбором альбома (albumId от 1 до 5) — при смене значения список загружается заново.

Итоги

  • Состояние — память компонента между рендерами; const [value, setValue] = useState(начальное).
  • Вызов сеттера перерисовывает компонент; если новое значение зависит от старого — setValue(prev => ...).
  • Объекты и массивы в состоянии не мутируют: {...obj}, [...arr], map, filter.
  • Управляемое поле: value={state} + onChange={e => setState(e.target.value)}.
  • Жизненный цикл: монтирование → обновления → размонтирование; побочные эффекты — в useEffect.
  • Массив зависимостей решает, когда запускать эффект ([] — один раз), а функция очистки убирает таймеры, подписки и устаревшие запросы.
Отзыв