Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. События и локальное состояние
events_state

События и локальное состояние

Обработчики, useState, функциональные обновления, моделирование минимального состояния и устранение рассинхронизации.

Открыть лабораториюv0.2.0Запускается локально из публичного репозитория

События и локальное состояние

Хорошая модель состояния хранит только то, что нельзя вычислить, и делает владельца каждого решения очевидным.

#Результат урока

Вы сможете обработать пользовательское намерение, выбрать минимальную форму состояния и устранить дубли, невозможные сочетания и скрытую связь компонентов.

Понадобятся снимки состояния, группировка обновлений, свойства и неизменяемые преобразования. Итог урока — фильтруемый список с выбором записи, редактированием и предсказуемыми переходами состояния.

#1. Обработчик передаётся, а не вызывается

Свойство события в JSX получает функцию. onClick={submit} передаёт ссылку, а onClick={submit()} вызывает функцию во время отрисовки и передаёт её результат. Стрелочная функция нужна, если есть аргументы. Обработчик — правильное место для действия, вызванного конкретным событием пользователя.

Разница в одной паре скобок меняет момент выполнения:

<button onClick={submit}>Отправить</button> {/* ✅ передана ссылка */} <button onClick={submit()}>Отправить</button> {/* ❌ вызов во время отрисовки */} <button onClick={() => remove(ticket.id)}>Удалить</button> {/* ✅ аргумент через стрелку */}

Второй вариант особенно коварен: если submit меняет состояние, отрисовка запросит новую отрисовку, и получится бесконечный цикл. Если submit отправляет запрос — команда уйдёт на сервер при каждом появлении компонента на экране, без всякого участия пользователя.

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

Частая ошибка. Вызвать setter или сетевую команду при построении JSX.

#2. Распространение события и явное намерение

Событие проходит фазы перехвата (capture) и всплытия (bubbling). stopPropagation останавливает распространение, а preventDefault отменяет стандартное действие браузера. Лучше передать обработчик предметного действия (onSelect) через свойства, чем заставлять родителя угадывать намерение по любому клику внутри карточки.

Не делайте всю карточку псевдокнопкой, если внутри неё есть другие действия. Интерактивные элементы должны быть семантическими соседями, а не вложенными кнопками:

// src/features/tickets/TicketCard.tsx function TicketCard({ ticket, onSelect, onDelete }: TicketCardProps) { return ( <article> <h3> <button type="button" onClick={() => onSelect(ticket.id)}> {ticket.title} </button> </h3> <button type="button" onClick={() => onDelete(ticket.id)} > Удалить </button> </article> ); }

Теперь выбор и удаление доступны с клавиатуры без ручной имитации поведения кнопки, а клик по удалению не может всплыть в обработчик выбора. stopPropagation остаётся инструментом для настоящих вложенных событий — например, чтобы клик внутри диалога не дошёл до обработчика подложки. Не используйте его для маскировки невалидной вложенности интерактивных элементов.

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

Частая ошибка. Назначить click всей вложенной разметке и отменять события во многих потомках.

#3. Минимальное состояние

Состояние хранит только изменяемый минимум. Если visibleItems определяется значениями items, query и sort, его вычисляют во время отрисовки. Дублированное состояние требует синхронизации и создаёт промежуточные несогласованные снимки. Дорогое чистое вычисление можно кешировать, но результат не становится отдельным источником истины.

Производное состояние с эффектом даёт лишнюю фиксацию и кадр с рассогласованными данными:

// ❌ три источника истины вместо одного const [items, setItems] = useState<Item[]>([]); const [filteredItems, setFilteredItems] = useState<Item[]>([]); const [isEmpty, setIsEmpty] = useState(true); useEffect(() => { const next = items.filter(item => item.title.includes(query)); setFilteredItems(next); setIsEmpty(next.length === 0); // ещё одна фиксация DOM }, [items, query]);

Вычисление во время отрисовки не может рассинхронизироваться в принципе:

// ✅ один источник истины, производные значения выводятся из него const [items, setItems] = useState<Item[]>([]); const [query, setQuery] = useState(''); const filteredItems = items.filter(item => item.title.includes(query)); const isEmpty = filteredItems.length === 0;

Если фильтрация действительно дорога — измерьте и оберните её в useMemo. Это кеш вычисления, а не новое состояние: значение по-прежнему выводится из items и query.

Проверьте себя. Удалите isEmpty и filteredItems из состояния, оставив исходные данные и параметры.

Частая ошибка. Эффект, который при каждом изменении свойств пересчитывает производное состояние через функцию обновления.

#4. Идентификатор вместо копии объекта

Выбранную запись лучше хранить как selectedId, а объект находить в актуальном массиве. Если сохранить копию объекта, обновление данных сервера не попадёт в выбранную запись. Идентификатор должен быть стабильным, а null допустим только тогда, когда выбора действительно может не быть.

Копия объекта — это снимок, который перестаёт обновляться:

// ❌ после обновления списка в карточке останется старое название const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); // ✅ идентификатор + поиск в актуальных данных const [selectedId, setSelectedId] = useState<string | null>(null); const selectedProduct = products.find(product => product.id === selectedId) ?? null;

Второй вариант заодно корректно обрабатывает удаление: если выбранный товар исчез из списка, selectedProduct станет null, и интерфейс покажет пустое состояние вместо данных несуществующей записи. С копией объекта такой сценарий пришлось бы отслеживать вручную.

Проверьте себя. Обновите название товара в исходных данных и убедитесь, что выбранная карточка показывает новое значение.

Частая ошибка. Хранить одновременно selectedId и selectedProduct без явной независимой причины.

#5. Нормализация формы состояния

Избегайте противоречивых логических флагов вроде isLoading, hasError, hasData. Размеченное объединение (discriminated union) или одно поле status задаёт конечный набор допустимых состояний. Вложенный объект обновляется копированием изменившегося пути. Большая глубина часто означает, что данные стоит нормализовать по идентификатору или разделить между владельцами.

Три логических флага дают восемь сочетаний, из которых осмысленны четыре:

// ❌ что показывать при isLoading && hasError? А при всех false? const [isLoading, setIsLoading] = useState(false); const [hasError, setHasError] = useState(false); const [hasData, setHasData] = useState(false); // ✅ ровно четыре достижимых состояния, компилятор требует обработать каждое type ListState = | { status: 'idle' } | { status: 'loading' } | { status: 'ready'; items: Item[] } | { status: 'failed'; message: string };

Обновление вложенного объекта копирует только изменившийся путь — остальные ссылки остаются прежними:

setForm(current => ({ ...current, contact: { ...current.contact, email: nextEmail }, // копируем путь до поля }));

Если такое копирование уходит на три уровня вглубь, это сигнал: данные пора нормализовать по идентификатору или разделить между владельцами.

Проверьте себя. Замените три логических флага загрузки на объединение вариантов и удалите недостижимые ветви JSX.

Частая ошибка. Разрешить одновременно isLoading и hasError без определённого смысла.

#6. Подъём состояния и управляемые компоненты

Если два соседних компонента должны видеть одно значение, состояние поднимают к ближайшему общему владельцу. Управляемый (controlled) компонент получает значение и обработчик изменения, неуправляемый хранит начальное значение локально. Не переключайте режим после монтирования. Поднимать всё к корню тоже не стоит: это расширяет область обновлений и публичные контракты.

// src/Toggle.tsx type ToggleProps = { pressed: boolean; onPressedChange(next: boolean): void; };

Пара свойств выражает управляемый интерфейс: родитель владеет значением, а компонент сообщает намерение его изменить.

Синхронизация двух копий эффектами — распространённая замена подъёму состояния, и она всегда хуже:

// ❌ две копии выбора и два эффекта, которые обновляют друг друга const [leftSelected, setLeftSelected] = useState<string | null>(null); const [rightSelected, setRightSelected] = useState<string | null>(null); useEffect(() => setRightSelected(leftSelected), [leftSelected]); useEffect(() => setLeftSelected(rightSelected), [rightSelected]); // ✅ одно значение у общего родителя, обе панели получают его свойством const [selectedId, setSelectedId] = useState<string | null>(null); <LeftPanel selectedId={selectedId} onSelect={setSelectedId} /> <RightPanel selectedId={selectedId} onSelect={setSelectedId} />

Первый вариант даёт лишние фиксации, кадр рассогласования и потенциальный цикл обновлений; второй делает владельца очевидным.

Проверьте себя. Свяжите выбор в двух панелях через ближайшего общего родителя.

Частая ошибка. Копировать свойство в локальное состояние каждого соседа и синхронизировать копии эффектами.

#7. Переходы как события предметной области

Обработчик лучше называть по намерению: approveRequest, а не handleClick, если функция выполняет предметный переход. Проверки и обновления группируются в одном месте, а интерфейс вызывает команду из разных источников. Это подготавливает код к редьюсеру и делает тесты независимыми от конкретной кнопки.

Чистый переход можно вызвать из формы, из горячей клавиши и из теста — и он одинаково проверяем:

// src/features/catalog/transitions.ts export function renameItem(state: CatalogState, payload: { id: string; title: string }): CatalogState { const title = payload.title.trim(); if (title === '') return state; // инвариант: пустое название не применяется return { ...state, items: state.items.map(item => (item.id === payload.id ? { ...item, title } : item)), }; }
// вызов из двух источников, логика одна function handleSubmit() { setState(current => renameItem(current, { id, title: draft })); } useHotkey('F2', () => setState(current => renameItem(current, { id, title: draft })));

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

Проверьте себя. Выделите чистый переход renameItem(state, payload) и вызовите его при отправке формы и по сочетанию клавиш.

Частая ошибка. Размазать один предметный переход по нескольким обработчикам DOM.

#8. Сброс, инициализация и свойства

Функция инициализации useState(() => expensive()) вызывается только при создании экземпляра компонента. Изменение свойства не переинициализирует состояние. Если черновик должен сбрасываться при смене записи, задайте новый key или поднимите черновик к владельцу. Синхронизация эффектом может на мгновение показать старые данные и стереть ввод.

Начальное значение читается ровно один раз, и это не ошибка, а контракт:

// ❌ при смене ticket поле не обновится: initial используется только при монтировании function TitleEditor({ ticket }: { ticket: Ticket }) { const [draft, setDraft] = useState(ticket.title); ... } // ✅ сброс через ключ: смена записи создаёт новый экземпляр с чистым черновиком <TitleEditor key={ticket.id} ticket={ticket} />

Альтернатива — синхронизация эффектом — выглядит рабочей, но теряет ввод пользователя:

// ❌ перезапишет черновик, если данные обновятся во время редактирования useEffect(() => setDraft(ticket.title), [ticket.title]);

Выбор между «сбросить» и «сохранить черновик по идентификатору» — продуктовое решение. Важно принять его явно, а не получить как побочный эффект синхронизации.

Проверьте себя. Переключите recordId во время редактирования и выберите правило: сохранить черновик по идентификатору или сбросить форму новым key.

Частая ошибка. Копировать каждое свойство в состояние «на случай редактирования».

#Лаборатория

Переработайте доску заявок: уберите сохранённые filteredItems и isEmpty, замените выбранный объект на идентификатор, оформите переходы состояния отдельными обработчиками и сохраните корректность при обновлении данных сервера.

Работа готова, когда каждое поле имеет одного владельца, производные значения вычисляются во время отрисовки, а быстрые события не теряют обновления.

#Источники для сверки

  • React: Responding to Events
  • React: Choosing the State Structure
  • React: Sharing State
  • React: Updating Objects in State

Устранение неисправностей

Далее: Формы и действия