Обработчики, useState, функциональные обновления, моделирование минимального состояния и устранение рассинхронизации.
Хорошая модель состояния хранит только то, что нельзя вычислить, и делает владельца каждого решения очевидным.
Вы сможете обработать пользовательское намерение, выбрать минимальную форму состояния и устранить дубли, невозможные сочетания и скрытую связь компонентов.
Понадобятся снимки состояния, группировка обновлений, свойства и неизменяемые преобразования. Итог урока — фильтруемый список с выбором записи, редактированием и предсказуемыми переходами состояния.
Свойство события в JSX получает функцию. onClick={submit} передаёт ссылку, а onClick={submit()} вызывает функцию во время отрисовки и передаёт её результат. Стрелочная функция нужна, если есть аргументы. Обработчик — правильное место для действия, вызванного конкретным событием пользователя.
Разница в одной паре скобок меняет момент выполнения:
<button onClick={submit}>Отправить</button> {/* ✅ передана ссылка */}
<button onClick={submit()}>Отправить</button> {/* ❌ вызов во время отрисовки */}
<button onClick={() => remove(ticket.id)}>Удалить</button> {/* ✅ аргумент через стрелку */}Второй вариант особенно коварен: если submit меняет состояние, отрисовка запросит новую отрисовку, и получится бесконечный цикл. Если submit отправляет запрос — команда уйдёт на сервер при каждом появлении компонента на экране, без всякого участия пользователя.
Проверьте себя. Проверьте, что команда удаления не вызывается до клика и получает правильный id.
Частая ошибка. Вызвать setter или сетевую команду при построении JSX.
Событие проходит фазы перехвата (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 всей вложенной разметке и отменять события во многих потомках.
Состояние хранит только изменяемый минимум. Если 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 из состояния, оставив исходные данные и параметры.
Частая ошибка. Эффект, который при каждом изменении свойств пересчитывает производное состояние через функцию обновления.
Выбранную запись лучше хранить как 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 без явной независимой причины.
Избегайте противоречивых логических флагов вроде 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 без определённого смысла.
Если два соседних компонента должны видеть одно значение, состояние поднимают к ближайшему общему владельцу. Управляемый (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} />Первый вариант даёт лишние фиксации, кадр рассогласования и потенциальный цикл обновлений; второй делает владельца очевидным.
Проверьте себя. Свяжите выбор в двух панелях через ближайшего общего родителя.
Частая ошибка. Копировать свойство в локальное состояние каждого соседа и синхронизировать копии эффектами.
Обработчик лучше называть по намерению: 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.
Функция инициализации 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, замените выбранный объект на идентификатор, оформите переходы состояния отдельными обработчиками и сохраните корректность при обновлении данных сервера.
Работа готова, когда каждое поле имеет одного владельца, производные значения вычисляются во время отрисовки, а быстрые события не теряют обновления.
Далее: Формы и действия