Состояние — это память компонента между отрисовками: useState возвращает текущее значение и функцию, которая сохраняет новое и просит React перерисовать компонент. Значение внутри одной отрисовки — неизменный снимок, поэтому обновление на основе предыдущего значения пишут функцией setCount(c => c + 1). Объекты и массивы в состоянии не мутируют, а заменяют новыми, а общее для нескольких компонентов состояние поднимают в ближайшего общего родителя.
После материала вы сможете
- объяснить, почему обычная переменная не вызывает перерисовку и почему состояние — снимок
- обновлять состояние через функцию
setX(prev => …), когда новое значение зависит от старого - менять объекты и массивы в состоянии без мутаций: распыление,
map,filter - сделать управляемое поле ввода и поднять общее состояние к родителю, вычисляя производные значения при отрисовке
Что знать заранее
Проверено на
React 19.3.0TypeScript 6.0.3TypeScript 7.0.2Vite 8.3.4Vitest 5.0.3@testing-library/react 16.3.3@testing-library/user-event 14.6.7Node.js 24 LTS (24.18.0)
Сверено с документацией: 11 октября 2026 г.
Содержание
Состояние как память компонента
В прошлом уроке каталог рисовал то, что пришло в props, и больше ничего не умел. Теперь интерфейс должен отвечать на действия: кнопка «В корзину» добавляет товар, «+» и «−» меняют количество, поле промокода даёт скидку. Для этого компоненту нужна память — состояние (state).
Первая попытка — обычная переменная. Её значение меняется, но экран остаётся прежним.
import { useState } from 'react'
export function BrokenCounter() {
let count = 0
function handleClick() {
count = count + 1 // значение меняется, но React об этом не знает
}
return <button onClick={handleClick}>Сломанный счётчик: {count}</button>
}
export function Counter() {
const [count, setCount] = useState(0)
function addThreeWrong() {
// в этом рендере count — снимок, например 0: трижды «заменить на 1»
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
}
function addThree() {
// функции обновления выстраиваются в очередь: 0 → 1 → 2 → 3
setCount((c) => c + 1)
setCount((c) => c + 1)
setCount((c) => c + 1)
}
return (
<div>
<p>Счётчик: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={addThreeWrong}>+3 со снимком</button>
<button onClick={addThree}>+3 через функцию</button>
</div>
)
}Документация React называет две причины, почему BrokenCounter не работает. Первая: локальные переменные не сохраняются между отрисовками — при следующем вызове функции count снова станет 0. Вторая: изменение локальной переменной не запускает отрисовку — React просто не знает, что данные поменялись.
Хук useState решает обе проблемы: даёт переменную состояния, которая сохраняется между отрисовками, и функцию-сеттер, которая обновляет значение и просит React отрисовать компонент заново. Запись const [count, setCount] = useState(0) — деструктуризация массива из двух элементов; 0 — начальное значение. TypeScript выводит тип из него: здесь number.
- Хуки — функции, имя которых начинается с
use. По правилам React их вызывают только на верхнем уровне компонента или своего хука — не внутри условий, циклов и вложенных функций. - Состояние принадлежит экземпляру компонента. Если отрисовать
<Counter />дважды, у каждой копии будет своё, независимое значение. - Состояние приватно: в отличие от props, родитель не может изменить его напрямую.
Состояние как снимок
Самое неожиданное в useState для новичка: вызов setCount не меняет переменную count, которая у вас уже есть. Документация формулирует это так: состояние ведёт себя как снимок. Сеттер просит React о новой отрисовке, и уже в ней count будет новым. Внутри текущей отрисовки — в том числе во всех её обработчиках — значение не меняется.
Это видно по кнопке «+3 со снимком». В обработчике трижды вызывается setCount(count + 1), но во всех трёх вызовах count — значение из этой отрисовки, например 0. Получается трижды «заменить на 1», и счётчик вырастает на единицу, а не на три. Это проверяет тест в конце урока.
setCount(count + 1) сразу меняет count
- Что происходит на самом деле
countв этой отрисовке прежний; новое значение придёт со следующей отрисовкой
console.log(count) после сеттера покажет новое значение
- Что происходит на самом деле
- покажет старое. Нужно новое — сохраните его в переменную:
const next = count + 1; setCount(next)
Три вызова setCount(count + 1) дадут +3
- Что происходит на самом деле
- дадут +1: все три читают один и тот же снимок
setTimeout в обработчике увидит свежее значение
- Что происходит на самом деле
- увидит значение из той отрисовки, в которой обработчик был создан
Обновление на основе предыдущего значения
Если новое значение зависит от предыдущего, передайте в сеттер не значение, а функцию обновления (updater): setCount((c) => c + 1). React ставит такие функции в очередь и после завершения обработчика выполняет их по порядку, передавая каждой результат предыдущей. Поэтому кнопка «+3 через функцию» действительно прибавляет три.
Документация объясняет очередь так: React дожидается, пока выполнится весь код обработчика события, и только потом обрабатывает обновления состояния — это называется пакетной обработкой (batching). В очереди могут смешиваться значения и функции:
setCount(count + 5)
- Что попадает в очередь
- «заменить на 5»
- Результат при
count = 0 - 5
setCount((c) => c + 1)
- Что попадает в очередь
- функция
c => c + 1 - Результат при
count = 0 - 5 + 1 = 6
setCount(42) после них
- Что попадает в очередь
- «заменить на 42» — всё предыдущее отбрасывается
- Результат при
count = 0 - 42
- Аргумент функции обновления по соглашению называют первой буквой переменной (
cдляcount) или полным именем с префиксомprev:setCart((prev) => …). - Функция обновления должна быть чистой: только вычислять новое значение из старого. В режиме разработки под StrictMode React вызывает её дважды, чтобы обнаружить случайные побочные эффекты.
- Функцию нельзя путать с вызовом:
useState(createInitialCart)вызовет инициализатор один раз, аuseState(createInitialCart())— на каждой отрисовке, отбрасывая результат.
Объекты и массивы: обновления без мутаций
Состояние может хранить объект или массив. Правило из документации React: относитесь ко всему, что лежит в состоянии, как к данным только для чтения. Нельзя менять объект или массив на месте — нужно создать новый и передать его сеттеру. Вот как выглядит ошибка:
function addToCart(id: string) {
const line = cart.find((l) => l.id === id)
if (line) {
line.qty += 1 // меняем объект, который лежит в состоянии
} else {
cart.push({ id, qty: 1 }) // меняем сам массив состояния
}
setCart(cart) // тот же массив: React сравнит через Object.is и пропустит рендер
}Данные действительно меняются, но setCart(cart) получает тот же самый массив. React сравнивает старое и новое значение через Object.is, видит совпадение и пропускает отрисовку. Мы проверили это тестом: после клика на экране остаются «Корзина пуста» и итог 0 ₸. Хуже того, мутация меняет и «старые» снимки, на которые могли опираться другие части кода.
function addToCart(id: string) {
setCart((prev) =>
prev.some((line) => line.id === id)
? prev.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line))
: [...prev, { id, qty: 1 }],
)
}Добавить
- Избегать (мутирует)
push,unshift- Использовать (возвращает новое)
[...arr, item],concat
Удалить
- Избегать (мутирует)
pop,shift,splice- Использовать (возвращает новое)
filter,slice
Заменить или изменить элемент
- Избегать (мутирует)
arr[i] = …,splice- Использовать (возвращает новое)
map
Отсортировать, развернуть
- Избегать (мутирует)
sort,reverseна исходном массиве- Использовать (возвращает новое)
- сначала скопировать:
[...arr].sort()
Изменить поле объекта
- Избегать (мутирует)
obj.qty = 2- Использовать (возвращает новое)
{ ...obj, qty: 2 }
Распыление ... копирует только один уровень. Чтобы обновить вложенное поле, копируют каждый уровень от изменённого места вверх: { ...order, delivery: { ...order.delivery, fee: 0 } }. Если вложенность глубокая, документация предлагает два пути: сделать структуру состояния более плоской или взять библиотеку Immer, которая позволяет писать «мутирующий» код и сама создаёт копии.
Управляемые поля ввода
Поле промокода — управляемый (controlled) элемент: его значение задаёт состояние, а не браузер. Связка всегда одна и та же: value из состояния и onChange, который синхронно записывает новое значение.
<input value={promo} onChange={(e) => setPromo(e.target.value.trim().toUpperCase())} />Так можно не только хранить ввод, но и сразу его нормализовать: обрезать пробелы, привести к верхнему регистру, отбросить лишние символы. Для чекбоксов вместо value используют checked и читают e.target.checked.
valueбезonChangeделает поле недоступным для ввода: React будет возвращать значение из состояния после каждого нажатия клавиши. В режиме разработки он предупредит об этом в консоли. Нужно только начальное значение — используйтеdefaultValue.valueне должен бытьundefinedилиnull: иначе React сочтёт поле неуправляемым, а при появлении строки предупредит о смене режима. Начальное значение пустого поля —'', для данных из API —value={name ?? ''}.- Значение поля — всегда строка, даже у
type="number". Храните строку и превращайте её в число при вычислениях:Number(qty).
Подъём состояния к общему родителю
Меню и корзина — разные компоненты, но работают с одними данными: кнопка в меню меняет содержимое корзины. Если бы каждый хранил свою копию, они быстро разошлись бы. Документация React описывает решение — подъём состояния (lifting state up): убрать состояние из дочерних компонентов, перенести его в ближайшего общего родителя и передать вниз через props вместе с обработчиками.
export interface MenuItem {
id: string
name: string
price: number // тенге
}
export interface CartLine {
id: string // ссылка на MenuItem, а не копия объекта
qty: number
}
export const menu: MenuItem[] = [
{ id: 'cappuccino', name: 'Капучино', price: 1600 },
{ id: 'latte', name: 'Латте', price: 1800 },
{ id: 'cheesecake', name: 'Чизкейк', price: 2400 },
]
export const PROMO_CODE = 'AUTUMN'
export const PROMO_PERCENT = 10import type { MenuItem } from '../data/menu.ts'
interface MenuProps {
items: MenuItem[]
onAdd: (id: string) => void
}
export function Menu({ items, onAdd }: MenuProps) {
return (
<ul className="menu">
{items.map((item) => (
<li key={item.id}>
{item.name} — {item.price} ₸{' '}
<button onClick={() => onAdd(item.id)}>В корзину</button>
</li>
))}
</ul>
)
}import type { CartLine, MenuItem } from '../data/menu.ts'
interface CartProps {
lines: CartLine[]
items: MenuItem[]
onChangeQty: (id: string, delta: number) => void
onRemove: (id: string) => void
}
export function Cart({ lines, items, onChangeQty, onRemove }: CartProps) {
if (lines.length === 0) {
return <p>Корзина пуста</p>
}
return (
<ul className="cart">
{lines.map((line) => {
const item = items.find((i) => i.id === line.id)
if (!item) return null
return (
<li key={line.id}>
{item.name}
<button aria-label={`Меньше: ${item.name}`} onClick={() => onChangeQty(line.id, -1)}>
−
</button>
<span aria-label={`Количество: ${item.name}`}>{line.qty}</span>
<button aria-label={`Больше: ${item.name}`} onClick={() => onChangeQty(line.id, 1)}>
+
</button>
{item.price * line.qty} ₸
<button onClick={() => onRemove(line.id)}>Удалить</button>
</li>
)
})}
</ul>
)
}import { useState } from 'react'
import { Cart } from './components/Cart.tsx'
import { Menu } from './components/Menu.tsx'
import { menu, PROMO_CODE, PROMO_PERCENT, type CartLine } from './data/menu.ts'
function App() {
// состояние живёт в общем родителе Menu и Cart
const [cart, setCart] = useState<CartLine[]>([])
const [promo, setPromo] = useState('')
function addToCart(id: string) {
setCart((prev) =>
prev.some((line) => line.id === id)
? prev.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line))
: [...prev, { id, qty: 1 }],
)
}
function changeQty(id: string, delta: number) {
setCart((prev) =>
prev
.map((line) => (line.id === id ? { ...line, qty: line.qty + delta } : line))
.filter((line) => line.qty > 0),
)
}
function removeLine(id: string) {
setCart((prev) => prev.filter((line) => line.id !== id))
}
// производные значения считаем при рендере, а не храним в состоянии
const subtotal = cart.reduce((sum, line) => {
const item = menu.find((i) => i.id === line.id)
return item ? sum + item.price * line.qty : sum
}, 0)
const promoApplied = promo === PROMO_CODE
const discount = promoApplied ? Math.round((subtotal * PROMO_PERCENT) / 100) : 0
const total = subtotal - discount
return (
<main>
<h1>Кофейня</h1>
<Menu items={menu} onAdd={addToCart} />
<h2>Корзина</h2>
<Cart lines={cart} items={menu} onChangeQty={changeQty} onRemove={removeLine} />
<label>
Промокод{' '}
<input value={promo} onChange={(e) => setPromo(e.target.value.trim().toUpperCase())} />
</label>
{promoApplied && <p>Скидка {PROMO_PERCENT}%: −{discount} ₸</p>}
<p>
<strong>Итого: {total} ₸</strong>
</p>
</main>
)
}
export default App- Единый источник истины: у каждого куска состояния есть один компонент-владелец. Корзиной владеет
App,MenuиCartтолько показывают данные и сообщают о действиях черезonAdd,onChangeQty,onRemove. MenuиCart— управляемые компоненты: их поведение полностью задают props. Компонент со своим внутренним состоянием, на которое родитель не влияет, документация называет неуправляемым.- В корзине хранятся
idи количество, а не копии товаров. Документация советует хранить идентификатор выбранного, а не сам объект: если цена в меню изменится, корзина сразу покажет новую. subtotal,discountиtotalне лежат в состоянии: их можно вычислить изcartиpromoпри каждой отрисовке. Лишнее состояние, которое нужно синхронизировать вручную, — частый источник ошибок.
Типичные ошибки, проверка и практика
Мутация объекта или массива
- Симптом
- данные поменялись, экран — нет
- Исправление
- создавать новый объект: распыление,
map,filter
Устаревшее значение
- Симптом
- +3 даёт +1;
console.logпосле сеттера показывает старое - Исправление
- функция обновления
setX(prev => …); новое значение — в отдельную переменную
onClick={handleClick()}
- Симптом
- ошибка Too many re-renders: сеттер вызывается во время отрисовки
- Исправление
- передать функцию:
onClick={handleClick}илиonClick={() => handleClick(id)}
Лишнее состояние
- Симптом
- итог расходится с корзиной
- Исправление
- вычислять производные значения при отрисовке
Копия props в состоянии
- Симптом
useState(props.color)не реагирует на новые props- Исправление
- использовать prop напрямую; если нужно только начальное значение — назвать prop
initialColor
Проверим поведение тестами. Проект создаётся так же, как в прошлом уроке; конфигурация Vitest и src/test/setup.ts — те же. Дополнительно нужен @testing-library/user-event: он имитирует клики и ввод текста так, как это делает пользователь.
npm create vite@latest coffee-cart -- --template react-ts
cd coffee-cart
npm install
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-eventimport { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import App from './App.tsx'
import { BrokenCounter, Counter } from './components/Counter.tsx'
describe('счётчики', () => {
it('обычная переменная не вызывает перерисовку', async () => {
const user = userEvent.setup()
render(<BrokenCounter />)
await user.click(screen.getByRole('button'))
expect(screen.getByRole('button').textContent).toBe('Сломанный счётчик: 0')
})
it('состояние — снимок: +3 со снимком прибавляет 1, через функцию — 3', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '+3 со снимком' }))
expect(screen.getByText('Счётчик: 1')).toBeTruthy()
await user.click(screen.getByRole('button', { name: '+3 через функцию' }))
expect(screen.getByText('Счётчик: 4')).toBeTruthy()
})
})
describe('корзина', () => {
it('добавляет, меняет количество, удаляет и считает итог', async () => {
const user = userEvent.setup()
render(<App />)
const addButtons = screen.getAllByRole('button', { name: 'В корзину' })
await user.click(addButtons[0]) // Капучино
await user.click(addButtons[0])
await user.click(addButtons[2]) // Чизкейк
expect(screen.getByLabelText('Количество: Капучино').textContent).toBe('2')
expect(screen.getByText('Итого: 5600 ₸')).toBeTruthy()
await user.click(screen.getByRole('button', { name: 'Меньше: Чизкейк' }))
expect(screen.queryByLabelText('Количество: Чизкейк')).toBeNull()
expect(screen.getByText('Итого: 3200 ₸')).toBeTruthy()
await user.click(screen.getByRole('button', { name: 'Удалить' }))
expect(screen.getByText('Корзина пуста')).toBeTruthy()
expect(screen.getByText('Итого: 0 ₸')).toBeTruthy()
})
it('управляемое поле: промокод приводится к верхнему регистру и даёт скидку', async () => {
const user = userEvent.setup()
render(<App />)
await user.click(screen.getAllByRole('button', { name: 'В корзину' })[1]) // Латте
await user.type(screen.getByLabelText('Промокод'), 'autumn')
expect(screen.getByLabelText<HTMLInputElement>('Промокод').value).toBe('AUTUMN')
expect(screen.getByText('Скидка 10%: −180 ₸')).toBeTruthy()
expect(screen.getByText('Итого: 1620 ₸')).toBeTruthy()
})
})$ npm test
> coffee-cart@0.0.0 test
> vitest run
RUN v5.0.3 …/coffee-cart
Test Files 1 passed (1)
Tests 4 passed (4)npm run build (проверка типов и сборка) проходит без ошибок.Состояние отвечает за то, что происходит в ответ на действия пользователя. Но иногда компоненту нужно синхронизироваться с тем, что находится вне React: таймером, подпиской, сервером. Этому посвящён следующий урок — useEffect, — и в нём же разберём, когда эффект не нужен вовсе.
Как проверено5 · пройденосверено 11 октября 2026 г.
- запущено, работает
src/components/Counter.tsx (BrokenCounter, Counter: +3 со снимком и через функцию)
vitest 5.0.3 + user-event 14.6.7: после клика BrokenCounter показывает 0; «+3 со снимком» → 1, затем «+3 через функцию» → 4
- запущено, работает
src/data/menu.ts, Menu.tsx, Cart.tsx, App.tsx, App.test.tsx (корзина, промокод)
проект create-vite 9.2.1 react-ts: npm run build (tsc -b на TypeScript 6.0.3 + vite build 8.3.4) — успешно; npm test — 4/4 (добавление, количество, удаление, итог, управляемое поле и скидка)
- запущено, работает
Фрагмент с мутацией addToCart
временно подставлен вместо addToCart, тест подтвердил: после клика «Корзина пуста» и «Итого: 0 ₸»; RU и EN варианты
- запущено, работает
Решения упражнений (очистка корзины, MAX_QTY = 10, CheckoutForm)
копия проекта ex/: tsc -b, vite build, vitest 6/6; фрагменты сверены программно
- запущено, работает
EN-версия кода
копии en/ и en-ex/ с переведёнными строками: tsc -b, vite build, vitest 4/4 и 6/6
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.