Как делать11 октября 2026 г.7 мин чтения
ReactШаг 3 из 8

React: состояние и useState

Коротко

Состояние — это память компонента между отрисовками: useState возвращает текущее значение и функцию, которая сохраняет новое и просит React перерисовать компонент. Значение внутри одной отрисовки — неизменный снимок, поэтому обновление на основе предыдущего значения пишут функцией setCount(c => c + 1). Объекты и массивы в состоянии не мутируют, а заменяют новыми, а общее для нескольких компонентов состояние поднимают в ближайшего общего родителя.

Пошаговый урокУровень: НачальныйХуки ReactПрактика: ~45–90 мин

После материала вы сможете

  • объяснить, почему обычная переменная не вызывает перерисовку и почему состояние — снимок
  • обновлять состояние через функцию 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 г.

Содержание
  1. 01Состояние как память компонента
  2. 02Состояние как снимок
  3. 03Обновление на основе предыдущего значения
  4. 04Объекты и массивы: обновления без мутаций
  5. 05Управляемые поля ввода
  6. 06Подъём состояния к общему родителю
  7. 07Типичные ошибки, проверка и практика

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

В прошлом уроке каталог рисовал то, что пришло в props, и больше ничего не умел. Теперь интерфейс должен отвечать на действия: кнопка «В корзину» добавляет товар, «+» и «−» меняют количество, поле промокода даёт скидку. Для этого компоненту нужна память — состояние (state).

Первая попытка — обычная переменная. Её значение меняется, но экран остаётся прежним.

src/components/Counter.tsx
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>
  )
}
Учебные счётчики: BrokenCounter — как не надо, Counter — с useState.

Документация 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 в обработчике увидит свежее значение

Что происходит на самом деле
увидит значение из той отрисовки, в которой обработчик был создан
По документации react.dev: State as a Snapshot и useState → Troubleshooting.

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

Если новое значение зависит от предыдущего, передайте в сеттер не значение, а функцию обновления (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
Пример из react.dev: Queueing a Series of State Updates.
  • Аргумент функции обновления по соглашению называют первой буквой переменной (c для count) или полным именем с префиксом prev: setCart((prev) => …).
  • Функция обновления должна быть чистой: только вычислять новое значение из старого. В режиме разработки под StrictMode React вызывает её дважды, чтобы обнаружить случайные побочные эффекты.
  • Функцию нельзя путать с вызовом: useState(createInitialCart) вызовет инициализатор один раз, а useState(createInitialCart()) — на каждой отрисовке, отбрасывая результат.

Объекты и массивы: обновления без мутаций

Состояние может хранить объект или массив. Правило из документации React: относитесь ко всему, что лежит в состоянии, как к данным только для чтения. Нельзя менять объект или массив на месте — нужно создать новый и передать его сеттеру. Вот как выглядит ошибка:

src/App.tsx
  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 и пропустит рендер
  }
Так нельзя. Фрагмент — замена addToCart для эксперимента.

Данные действительно меняются, но setCart(cart) получает тот же самый массив. React сравнивает старое и новое значение через Object.is, видит совпадение и пропускает отрисовку. Мы проверили это тестом: после клика на экране остаются «Корзина пуста» и итог 0 ₸. Хуже того, мутация меняет и «старые» снимки, на которые могли опираться другие части кода.

src/App.tsx
  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 }],
    )
  }
Правильно: новый массив через map или распыление и новые объекты для изменённых строк.

Добавить

Избегать (мутирует)
push, unshift
Использовать (возвращает новое)
[...arr, item], concat

Удалить

Избегать (мутирует)
pop, shift, splice
Использовать (возвращает новое)
filter, slice

Заменить или изменить элемент

Избегать (мутирует)
arr[i] = …, splice
Использовать (возвращает новое)
map

Отсортировать, развернуть

Избегать (мутирует)
sort, reverse на исходном массиве
Использовать (возвращает новое)
сначала скопировать: [...arr].sort()

Изменить поле объекта

Избегать (мутирует)
obj.qty = 2
Использовать (возвращает новое)
{ ...obj, qty: 2 }
Таблица по react.dev: Updating Arrays in State.

Распыление ... копирует только один уровень. Чтобы обновить вложенное поле, копируют каждый уровень от изменённого места вверх: { ...order, delivery: { ...order.delivery, fee: 0 } }. Если вложенность глубокая, документация предлагает два пути: сделать структуру состояния более плоской или взять библиотеку Immer, которая позволяет писать «мутирующий» код и сама создаёт копии.

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

Поле промокода — управляемый (controlled) элемент: его значение задаёт состояние, а не браузер. Связка всегда одна и та же: value из состояния и onChange, который синхронно записывает новое значение.

src/App.tsx
<input value={promo} onChange={(e) => setPromo(e.target.value.trim().toUpperCase())} />
Фрагмент App.tsx: пользователь вводит autumn, в поле и в состоянии оказывается AUTUMN.

Так можно не только хранить ввод, но и сразу его нормализовать: обрезать пробелы, привести к верхнему регистру, отбросить лишние символы. Для чекбоксов вместо 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 вместе с обработчиками.

src/data/menu.ts
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 = 10
src/components/Menu.tsx
import 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>
  )
}
src/components/Cart.tsx
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>
  )
}
src/App.tsx
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
Симптомы и исправления — по разделам Troubleshooting и Choosing the State Structure на react.dev.

Проверим поведение тестами. Проект создаётся так же, как в прошлом уроке; конфигурация Vitest и src/test/setup.ts — те же. Дополнительно нужен @testing-library/user-event: он имитирует клики и ввод текста так, как это делает пользователь.

bash
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-event
src/App.test.tsx
import { 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()
  })
})
terminal
$ 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.