learn-se · Урок 7 · ~16 минут · два реальных бага

State-баги, которые ловит только запуск

Урок 5 сказал: «зелёный tsc ≠ работает». Этот урок объясняет почему — на двух настоящих багах из среза питания. Оба прошли типы. Оба поймал только живой драйв. И — сюрприз — второй баг родил фикс первого. Ты не пишешь код, но должен знать, где именно у экрана появляется невидимая для типов дыра.

Одна идея урока

tsc проверяет форму данных в застывший момент. State-баги живут не в форме, а во времени — в порядке событий: что загрузилось раньше, что позже, что пользователь успел напечатать между ними. Время типам не видно. Его видно только запуску.

1 · Баг первый: инициализатор сработал один раз — до данных

Карточка питания предзаполняет поля сегодняшней записью. Первая версия читала запись прямо в инициализаторе состояния:

Что написали
const todayEntry = log.find(e => e.date === today);
const [fields, setFields] = useState({
  protein: todayEntry?.protein ?? '',   // ← читается ОДИН раз
  ...
});

Типы идеальны. tsc зелёный. Но log приходит асинхронно — инициализатор отработал в первый рендер, когда log ещё пустой. todayEntry был undefined, поля стали пустыми — и такими и остались. Открываешь карточку после перезагрузки: пусто, хотя запись есть на сервере.

Почему типы слепы. Тип fieldsRecord<Macro, string>. Он верен в каждый момент. Баг не в форме значения, а в моменте, когда его прочитали — раньше, чем данные пришли. У «когда» нет типа.
Почему обзор не помог. Читая дифф глазами, ты видишь корректный useState с дефолтами. Форма кода образцовая. Дыра — в невидимой оси времени: «инициализатор бежит один раз, до загрузки». Это не читается, это наступает.
Что поймало. Только запуск с перезагрузкой. Драйв: сохранил → reload → карточка пустая. Read-back по GET /api/nutrition показал, что запись на сервере есть — значит баг в UI-гидрации, не в записи.

2 · Баг второй: фикс первого создал гонку

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

Фикс №1 — и новый баг в нём
useEffect(() => {
  if (!todayEntry) return;
  setFields({ protein: String(todayEntry.protein), ... });
}, [todayEntry]);   // ← бежит, КОГДА данные пришли

Пустую карточку починил. Но открылась гонка: пользователь начинает печатать до того, как log догрузился. Данные приходят на секунду позже — эффект срабатывает и затирает только что введённые цифры серверными. Драйв напечатал 150, а POST ушёл со старым 160. Это race condition: два источника пишут в одно поле, выигрывает тот, кто пришёл последним — и это оказался не пользователь.

Фикс №2 — гидрировать один раз и только если не трогали
const hydratedRef = useRef(false); // уже заполнили из сервера?
const dirtyRef = useRef(false);    // юзер начал печатать?

useEffect(() => {
  if (hydratedRef.current || dirtyRef.current || !todayEntry) return;
  hydratedRef.current = true;
  setFields({ ... });
}, [todayEntry]);
// onChange: dirtyRef.current = true; setFields(...)

Правило разрулило конфликт по приоритету: ввод пользователя всегда главнее прилетевших данных. Гидрируем ровно раз и только пока поле «чистое». Re-драйв (напечатать во время загрузки, потом reload) прошёл целиком.

Мораль, которую ты уже сформулировал в уроке 5: фикс — это тоже изменение, его надо перепроверять запуском. Первый фикс закрыл видимый баг и тихо открыл гонку. Зелёный tsc после фикса не значит ничего: типы одинаково довольны и багом, и лекарством, и лекарством с новым багом.

3 · Почему у этого класса нет типа — и как его ловить

Оба бага — из одного семейства: гонка асинхронной загрузки и локального состояния. Форма данных всё время корректна; ломается порядок. У порядка событий нет статического типа, поэтому инструмент, который смотрит на застывший снимок (типы, чтение диффа глазами), его пропустит by design.

Красный флаг №1. Инициализатор состояния читает данные, которые приходят асинхронно (useState(() => asyncData…)). Спроси: «а этот источник уже загружен в первый рендер?» Обычно — нет.
Красный флаг №2. useEffect, который пишет в поле формы по приходу серверных данных. Спроси: «а если пользователь уже печатает?» Нужен приоритет: ввод > данные (dirty-guard).
Как ловить — драйв со временем. Не «открыл и посмотрел», а с таймингом: печатай во время загрузки; перезагружай и жди гидрацию; сверяй отправленное с введённым. Баг наступает только в правильном порядке событий — воспроизведи порядок.
Что требовать у агента для форм с загрузкой

Когда просишь «предзаполни форму сохранённым значением», добавь в критерии приёмки два тайминг-сценария — иначе оба бага пройдут tsc и приедут к тебе:

1. Сохранил → перезагрузил → поле предзаполнено (не пусто).
2. Начал печатать ДО загрузки → мой ввод не затёрт данными.

Это ровно те два, что поймали баги. Тайминг-сценарий в спеке дешевле, чем баг в проде.

Проверь себя

1. Поле формы должно предзаполниться сохранённым значением, но после перезагрузки пусто. tsc зелёный. Наиболее вероятная причина?

Ровно баг №1. useState(() => asyncData) бежит один раз в первый рендер — до загрузки. Read-back покажет, что на сервере значение ЕСТЬ, значит дыра в гидрации UI, а не в записи.

2. Почему tsc в принципе не может поймать этот класс багов?

Форма (Record<Macro,string>) корректна в каждый миг. Ломается последовательность: что загрузилось раньше, что напечатали между. Порядок событий статике не виден by design.

3. Фикс пустой карточки — useEffect, заполняющий поле при загрузке данных. Что он тихо ломает?

Баг №2. Два источника пишут в одно поле, выигрывает поздний — и это оказались данные, а не пользователь. Нужен приоритет «ввод > данные»: гидрировать один раз и только пока поле не dirty.

4. Агент прислал фикс, tsc снова зелёный. Достаточное доказательство, что починил и не сломал?

Типы одинаково довольны багом, лекарством и лекарством с новым багом. Именно первый фикс открыл гонку. Правило урока 5: фикс перепроверяется драйвом, а не повторным tsc.

Упражнение — предскажи баг по описанию

Я даю тебе описание будущего среза. Не читая кода, назови тайминг-сценарий, который надо потребовать в приёмке, и класс бага, который он ловит.

  1. Срез А. «Экран профиля предзаполняет имя пользователя из /api/auth/me и даёт его отредактировать». Какой тайминг-сценарий обязателен? Какой баг он ловит?
  2. Срез Б. «Список сообщений подгружается, поле ответа предзаполнено черновиком из localStorage». Где здесь возможна гонка загрузка-vs-ввод?
  3. Правило. Сформулируй одним предложением, когда форму с предзаполнением обязательно гонять драйвом со временем, а не просто «открыл и посмотрел».

Бонус — поймай слабое доказательство. Ниже отчёт агента. Найди дыру до того, как раскроешь.

Отчёт агента (фрагмент)

— Починил пустое поле: добавил useEffect, заполняющий имя при загрузке /api/auth/me.
— Проверил: перезагрузил страницу, имя на месте. Работает ✅.
tsc зелёный.

Показать разбор
Разбор

Проверен только сценарий «reload → поле на месте» — тот самый, что чинили. Не проверен второй тайминг: «начал редактировать имя до того, как /api/auth/me ответил». Это ровно баг №2 из урока — эффект догрузки затрёт правку пользователя. Отчёт закрыл один тайминг и слеп ко второму, который сам же и открыл. Правильная приёмка: reload → предзаполнено и печать-во-время-загрузки → не затёрто.

Что уносишь

Есть целый класс багов, которым tsc и чтение диффа глазами слепы принципиально: они живут во времени, а не в форме. Два маркера — инициализатор, читающий async-данные, и эффект, пишущий в поле по приходу данных. Ловятся они одним приёмом: драйв со временем — воспроизвести порядок событий (печать во время загрузки, перезагрузка, сверка отправленного). И помни: фикс — это изменение; после него запускай заново, ведь лекарство от одного тайминг-бага легко рождает другой.

Источники: React — You Might Not Need an Effect · React — initial state runs once · Fowler — недетерминизм · живой кейс: срез питания setlog