Состояние хранит данные, которые компонент помнит между рендерами и которые меняются со временем. useState(initial) возвращает пару: текущее значение и сеттер; вызов сеттера сохраняет новое значение и планирует ререндер этого компонента.
const [count, setCount] = useState(0) использует деструктуризацию массива, поэтому имена выбираете вы; принято называть их x и setX.
Начальное значение используется только в первом рендере; в следующих рендерах аргумент игнорируется.
Состояние приватно для каждого экземпляра: два элемента <Counter /> хранят отдельные счётчики.
React хранит состояние вне функции и сопоставляет его с хуками по порядку вызова, поэтому хуки вызывают на верхнем уровне компонента, а не внутри условий или циклов.
TypeScript выводит тип из начального значения; если стейт начинается с null, тип указывают явно: useState<User | null>(null).
ReactJunior
Состояние и события
useState, состояние как снимок, батчинг, функция-апдейтер, иммутабельные обновления объектов и массивов, подъём состояния, события
Закрыто вопросов
0 / 20
- 0Повторить
- 9Часто спрашивают
Вопросы
Локальная переменная создаётся заново при каждом рендере, а её изменение ничего не сообщает React, поэтому ререндера нет, и следующий рендер снова начинается с начального значения. Сеттер стейта и сохраняет значение между рендерами, и запускает ререндер.
Нет триггера: React перерисовывает компонент, когда меняется его состояние, перерисовывается родитель или меняется контекст, который он читает; count++ над локальной переменной не относится ни к одному из этих случаев.
Нет памяти: функция компонента выполняется с нуля при каждом рендере, поэтому let count = 0 каждый раз сбрасывается.
useRef хранит значение между рендерами без ререндера; он подходит для значений, которые не выводятся на экран, например id таймеров и DOM-узлов.
Переменная на уровне модуля переживает рендеры, но она общая для всех экземпляров и протекает между тестами и запросами на сервере.
В проп в camelCase, например onClick или onChange, передают функцию, и React вызывает её с объектом события, когда событие происходит. Чтобы передать аргумент, вызов оборачивают в стрелочную функцию: onClick={() => remove(item.id)}.
Передавайте функцию, а не вызывайте её: onClick={handleClick} правильно, а onClick={handleClick()} выполнит обработчик прямо во время рендера.
Обработчики обычно объявляют внутри компонента, чтобы они видели текущие пропсы и стейт. Их называют handleX, а пропсы, которые их принимают, onX.
Событие представляет собой SyntheticEvent с тем же интерфейсом, что и нативное (e.target, e.preventDefault()); в TypeScript его типизируют как React.MouseEvent<HTMLButtonElement> или React.ChangeEvent<HTMLInputElement>.
Пропсы событий работают только на встроенных элементах; у вашего компонента onSelect остаётся обычным пропом, который компонент должен вызвать сам.
Обработчики подходят для побочных эффектов, вызванных действием пользователя: запрос при отправке формы, аналитика при клике.
Компонент вызывает setUpper во время рендера: каждый вызов планирует новый рендер, который снова его вызывает, и React обрывает цикл ошибкой «Too many re-renders. React limits the number of renders to prevent an infinite loop.»
Рендер должен быть чистым: он вычисляет JSX из пропсов и стейта, без сеттеров, запросов и записи в DOM.
Здесь значение выводится из пропа, поэтому ему вообще не место в стейте: его вычисляют во время рендера.
Та же ошибка возникает от onClick={setOpen(true)} в обычном JavaScript: сеттер срабатывает во время рендера, а не по клику. Передавайте функцию: onClick={() => setOpen(true)}.
React допускает сеттер во время рендера только в одном защищённом паттерне, когда стейт подстраивают под изменившийся проп (if (prev !== value)), и даже его обычно лучше заменить на key.
React решает, нужен ли ререндер, сравнивая старое и новое состояние через Object.is, поэтому мутированный объект остаётся той же ссылкой, и обновление может быть пропущено. Новые объекты к тому же сохраняют старые снимки нетронутыми, а на это опираются мемоизация, зависимости эффектов и конкурентный рендеринг.
Пропуск рендера: setUser(user) после user.name = 'Ann' передаёт ту же ссылку; React не видит изменений и не перерисовывает компонент.
Мемоизация и зависимости: React.memo, useMemo и зависимости эффектов сравнивают по ссылке, поэтому при мутации они пропускают изменения.
Снимки: замыкания из прошлых рендеров всё ещё держат старый объект; мутация переписывает прошлое, и ломаются undo, переходы (transitions) и отладка.
Поверхностной копии достаточно: копируют только путь, который меняется ({ ...obj, field }); нетронутые вложенные объекты переиспользуются, и это дёшево.
Мутировать только что созданную копию можно, а Immer (useImmer, Redux Toolkit) позволяет писать в мутирующем стиле и получать новый объект.
Нужно создать новый объект через spread с изменённым полем и передать его в сеттер. Для вложенного поля копируют каждый уровень на пути к нему: и внешний объект, и внутренний.
setForm({ ...form, email }) копирует поля верхнего уровня и перезаписывает одно из них.
Вложенный объект: { ...person, address: { ...person.address, city } }. Если развернуть только внешний уровень, останется старый внутренний объект, и баг возникает как раз при его мутации.
Вычисляемый ключ позволяет одним обработчиком обновлять любое поле по имени: { ...form, [e.target.name]: e.target.value }.
Глубокая вложенность говорит о том, что стейт стоит сделать плоским или взять Immer (produce, useImmer), чтобы обновление читалось как мутация.
structuredClone копирует всё целиком: это расточительно и ломает равенство ссылок для частей, которые не менялись.
Сеттер useState заменяет стейт ровно тем, что вы передали, и ничего не сливает. setFilters({ query }) сохраняет объект только с query, поэтому category становится undefined, и select переключается на пустой вариант All.
Привычка из классов: this.setState({ query }) вливал поля в существующий стейт, поэтому этот баг часто приезжает вместе с кодом, перенесённым из классового компонента.
Исправление: скопировать предыдущий объект, setFilters((prev) => ({ ...prev, query })); форма с функцией-апдейтером к тому же не даёт двум быстрым обновлениям затереть друг друга.
Почему TypeScript не поймал: все поля Filters необязательные, поэтому { query } является корректным Filters. С обязательными полями вызов не скомпилируется: «Property 'category' is missing».
Или разделите стейт: несвязанные значения вроде query и category можно хранить в двух вызовах useState, и тогда сливать нечего.
Используйте методы, которые возвращают новый массив: spread для добавления, filter для удаления, map для обновления, и передавайте результат в сеттер. Не вызывайте push, splice, sort и не присваивайте по индексу на самом массиве из стейта, потому что они его мутируют.
Добавить: [...items, item] в конец, [item, ...items] в начало, items.toSpliced(i, 0, item) в середину.
Удалить: items.filter((t) => t.id !== id).
Обновить один элемент: items.map((t) => (t.id === id ? { ...t, done: !t.done } : t)); копировать нужно и изменённый элемент, а не только массив.
Отсортировать или развернуть: toSorted() и toReversed() (ES2023) возвращают копии; либо сначала скопируйте массив: [...items].sort().
Используйте форму с функцией-апдейтером, setItems((prev) => ...), когда до следующего рендера может произойти несколько обновлений.
Поднять состояние значит перенести стейт из двух компонентов в их ближайшего общего родителя и передавать его вниз через пропсы вместе с колбэками, которые его меняют. Так делают, когда два компонента должны всегда показывать или менять одни и те же данные.
Родитель становится единственным источником истины, а дети становятся контролируемыми: они получают значение и колбэк в стиле onChange.
Поднимайте стейт только до ближайшего общего родителя; если поднять выше, без пользы будет перерисовываться большая часть дерева.
Когда данные приходится протаскивать через много слоёв, которые их не используют (prop drilling), берите композицию через children или контекст, а не поднимайте стейт ещё выше.
Обратное движение тоже правильно: стейт, который нужен только одному ребёнку, должен жить в этом ребёнке.
Каждый рендер видит фиксированное значение стейта на всё время своего существования: вызов сеттера не меняет уже имеющуюся у вас переменную, он просит React отрендерить компонент снова с новым значением. Код после setCount(count + 1) в том же обработчике всё ещё читает старый count.
count остаётся обычной const, которую захватили замыкания этого рендера: обработчики, таймауты и эффекты.
setTimeout или код после await, запущенный в старом рендере, читает стейт того рендера, даже если экран уже обновился. Чтобы вычислить значение от актуального, используйте функцию-апдейтер, а чтобы прочитать актуальное позже, используйте ref.
Чтобы сразу использовать новое значение, сначала вычислите его в локальную переменную: const next = count + 1; setCount(next); save(next);.
Именно это делает React предсказуемым: обработчик никогда не видит наполовину обновлённый стейт, и рендеры не мешают друг другу.
Вместо значения передают функцию, setCount((c) => c + 1), и React вызывает её с последним ожидающим значением стейта, когда обрабатывает очередь обновлений. Её используют, когда следующее состояние зависит от предыдущего и до ререндера может случиться несколько обновлений.
setCount(count + 1) три раза подряд в одном обработчике прибавит 1, потому что каждый вызов читает один и тот же снимок; setCount((c) => c + 1) три раза прибавит 3.
Апдейтер также лечит устаревшие замыкания: setInterval или эффект может обновлять стейт, не указывая этот стейт в зависимостях.
Апдейтеры должны быть чистыми: в StrictMode в режиме разработки React вызывает их дважды, чтобы поймать побочные эффекты.
Аргумент называют по имени стейта (c, prevItems), чтобы было понятно, что в нём лежит.
Нужен он не везде: setName(e.target.value) заменяет значение и не зависит от предыдущего.
Alert покажет 0, а кнопка затем покажет 1. Сеттеры не меняют number в уже выполняющемся обработчике; они ставят обновления в очередь, и React применяет их по порядку в следующем рендере: заменить на 5, затем 5 + 1 = 6, затем заменить на 0 + 1 = 1.
В очереди значение означает «заменить на это», а функция означает «вычислить из текущего результата».
number во всех трёх вызовах равен 0, потому что это снимок текущего рендера; поэтому последний вызов затирает два первых.
alert(number) выполняется до всякого ререндера, поэтому показывает старое значение.
Все три обновления батчатся в один рендер. Чтобы получить 6, уберите последний вызов.
Батчинг означает, что React объединяет несколько обновлений стейта в один ререндер, а не перерисовывает компонент после каждого вызова сеттера. Начиная с React 18 (createRoot) батчинг работает везде: в промисах, setTimeout и нативных обработчиках событий, а не только в обработчиках React.
До React 18 обновления внутри setTimeout или после await вызывали отдельный рендер на каждый сеттер.
Обновления применяются после завершения обработчика, поэтому DOM никогда не меняется посреди него.
Между отдельными действиями пользователя React не батчит: два клика дают два рендера, и второй клик видит результат первого.
flushSync(() => setX(...)) из react-dom принудительно выполняет синхронный рендер, например чтобы прокрутить к только что добавленному элементу; это бьёт по производительности, поэтому используйте его редко.
Из-за батчинга чтение стейта сразу после сеттера тоже возвращает старое значение.
push мутирует существующий массив, а setTags(tags) передаёт ту же ссылку, поэтому проверка Object.is не видит изменений, и React пропускает ререндер.
Данные в памяти действительно изменились, поэтому следующий рендер (из-за ввода текста) показывает тег; до этого экран рассинхронизирован с данными.
Исправление: создать новый массив, setTags((prev) => [...prev, text]), чтобы ссылка поменялась.
Тот же баг дают sort(), reverse(), splice(), а также obj.field = x с последующим setObj(obj).
Мутация ломает и всё, что сравнивает по ссылке: дочерние компоненты в React.memo, useMemo и зависимости эффектов.
React сравнивает новое и текущее значение через Object.is, и если они равны, пропускает ререндер компонента и его детей. React может один раз вызвать функцию компонента перед тем, как отказаться от обновления, но ничего не закоммитит.
Для примитивов это обычное равенство: setCount(5), когда count уже равен 5, ничего не делает.
Для объектов и массивов важна только ссылка: новый объект с тем же содержимым (setUser({ ...user })) всегда вызывает ререндер, а мутированный старый не вызывает.
Object.is отличается от === в двух случаях: NaN равен NaN, а +0 не равен -0.
Поэтому многократный вызов setOpen(false) безопасен, и проверка if (open) перед ним не нужна.
Если передать функцию, useState(() => compute()), React вызовет её только в первом рендере. С useState(compute()) функция выполняется при каждом рендере, а её результат после первого выбрасывается; это важно, когда вычисление дорогое, например парсинг localStorage.
Передавайте саму функцию, useState(createInitialTodos), а не результат её вызова useState(createInitialTodos()).
Инициализатор должен быть чистым и не принимать аргументов; в режиме разработки StrictMode вызывает его дважды.
Начальное значение читается один раз: если оно приходит из пропа, последующие изменения этого пропа стейт не сбрасывают.
У useReducer(reducer, arg, init) есть такая же ленивая форма через третий аргумент.
Храните минимальный стейт, который нельзя вычислить: всё, что выводится из пропсов или другого стейта, вычисляется во время рендера, а не хранится. Избегайте дублирования данных, противоречивых флагов и копий пропсов, потому что каждую лишнюю копию приходится синхронизировать вручную.
Вычисляйте, а не храните: fullName из firstName и lastName, visibleTodos из todos и filter. useMemo добавляйте, только если вычисление заметно медленное.
Храните id, а не копии: selectedId вместо selectedItem, чтобы правка элемента в списке не оставляла устаревшую копию.
Один статус вместо нескольких булевых флагов: status: 'idle' | 'sending' | 'sent' вместо isSending и isSent, которые могут оказаться true одновременно.
Не зеркальте пропсы: useState(props.color) читает проп один раз и игнорирует последующие изменения; читайте проп напрямую или назовите его initialColor, если игнорировать изменения задумано.
Группируйте то, что меняется вместе: один объект { x, y } для позиции и отдельный стейт для несвязанных значений.
React оборачивает нативные события в SyntheticEvent, вешает по одному слушателю на каждый тип события на корневой контейнер и сам проводит событие по дереву компонентов. e.preventDefault() отменяет действие браузера по умолчанию, а e.stopPropagation() не даёт событию дойти до обработчиков родителей.
События всплывают по дереву React: клик по кнопке внутри div с onClick вызовет оба обработчика, сначала у ребёнка.
Суффикс Capture в имени (onClickCapture) запускает обработчик на пути вниз, до детей; это удобно для логирования или блокировки кликов.
Начиная с React 17 слушатели висят на корневом элементе, а не на document, поэтому несколько React-приложений на одной странице и ваши собственные слушатели на document ведут себя предсказуемо.
События из портала всплывают к его родителю в дереве React, а не в DOM-дереве: клик внутри модалки доходит до компонента, который её отрендерил.
Пулинга событий нет с React 17, поэтому читать e после await безопасно; e.nativeEvent даёт событие браузера.
Не кладите каждое событие в стейт. Держите сырое значение в ref или пишите его прямо в DOM, а стейт меняйте, только когда должно измениться то, что рендерит React: производное значение вроде isScrolled или не чаще одного раза за кадр анимации.
Почему это больно: такие события приходят десятки раз в секунду, и сеттер на каждом перерисовывает компонент и его детей, поэтому прокрутка и перетаскивание дёргаются. Значение, которое нужно отрендерить, держите в маленьком компоненте, который его показывает.
Храните грубое значение: setScrolled(window.scrollY > 0) вызывает ререндер, только когда булево значение меняется, потому что сеттер с текущим значением ничего не делает.
Одно обновление за кадр: запланируйте один requestAnimationFrame, читайте в нём последнее значение и отменяйте его в функции очистки.
Обходите React для чистой визуализации: полосы прогресса, параллакс и ручки перетаскивания меняют transform или CSS-переменную через ref.
Observer вместо слушателей: IntersectionObserver для видимости и бесконечной прокрутки, ResizeObserver для размера элемента; position: sticky вообще не требует JavaScript.
Пассивные слушатели: { passive: true } на touchmove или wheel обещает не вызывать preventDefault, поэтому прокрутка никогда не ждёт обработчик. React уже вешает onTouchStart, onTouchMove и onWheel как пассивные, так что чтобы заблокировать прокрутку, добавьте нативный слушатель с { passive: false } в эффекте.
Храните историю вместо одного значения: past, present и future в одном useReducer. Изменение кладёт present в конец past и очищает future; undo переносит present в начало future и берёт последний элемент past; redo делает обратное.
Модель
Один редьюсер обновляет все три части вместе, поэтому они не расходятся, а dispatch остаётся стабильным для детей и эффектов.
Иммутабельные обновления делают это дешёвым: каждый снимок разделяет с предыдущим все неизменённые объекты, так что история хранит ссылки, а не глубокие копии. Мутация незаметно переписала бы прошлое.
**Новое изменение после undo очищает future: отменённая ветка исчезает, как в любом редакторе.
canUndo и canRedo вычисляются из длин массивов, а не хранятся.
О чём спросят дальше
Память**: ограничьте past, например 100 записями.
Ввод текста: запись на каждое нажатие клавиши делает undo бесполезным; пока пользователь печатает, обновляйте present без записи в историю и фиксируйте её после паузы или на blur.
Горячие клавиши: обрабатывайте Ctrl+Z и Ctrl+Shift+Z (Cmd на macOS) в слушателе keydown и пропускайте текстовые поля, у которых есть свой нативный undo.
Что не попадает в историю: выделение, позиция прокрутки и флаги загрузки; в истории место только документу.
Когда снимки перестают масштабироваться
Для больших документов храните изменения вместо копий: produceWithPatches из Immer (после enablePatches()) возвращает патчи и обратные патчи, и undo применяет обратные.
Отмена уже сохранённого изменения означает компенсирующий запрос, а редакторы для совместной работы держат стек undo для каждого пользователя, как UndoManager в Yjs. redux-undo и middleware zundo для Zustand используют ту же модель past, present и future.