Урок 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». Почему?
- React не знает, что переменная изменилась, и не перерисовывает компонент.
- Даже если бы перерисовал — функция
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.
Жизненный цикл компонента
У каждого компонента есть «жизнь» из трёх этапов:
- Монтирование (mount) — компонент впервые появился на странице.
- Обновление (update) — компонент перерисовался, потому что изменились состояние или пропсы.
- Размонтирование (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— посчитайте прямо в теле компонента. - Забыли очистку у таймеров и подписок.
Практика
- Счётчик. Кнопки «−», «+» и «Сбросить». Счётчик не должен уходить ниже нуля. Ожидаемый результат: «−» на нуле ничего не делает.
- Показать пароль. Поле пароля с кнопкой-глазом, которая переключает
typeмеждуpasswordиtext. Ниже — подсказка «Пароль слишком короткий», пока длина меньше 8. - Список дел в памяти. Повторите
ShoppingListс нуля, добавьте кнопку «Удалить купленные» и счётчик «Осталось купить: N». - Заголовок вкладки. Сделайте так, чтобы
document.titleпоказывал «(N) Список покупок», где N — число некупленных товаров. ИспользуйтеuseEffectс правильными зависимостями. - Галерея с сервера. Загрузите
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. - Массив зависимостей решает, когда запускать эффект (
[]— один раз), а функция очистки убирает таймеры, подписки и устаревшие запросы.