ментальные модели React: как мыслить компонентами, состоянием и эффектами, чтобы быстро собирать UI через чат и не терять предсказуемость.

React сбивает с толку не потому, что он «слишком сложный», а потому что новички часто держат в голове неправильную картинку. Кажется, что UI нужно «обновлять вручную», что рендер - это событие, которым можно управлять, а эффекты работают как скрытая магия. В такой картине мира любой баг выглядит случайным.
Ментальные модели React помогают заменить набор приемов на несколько простых правил. Dan Abramov много раз объяснял React именно так: меньше мистики, больше ясных договоренностей. Когда картинка в голове правильная, вы быстрее пишете код и проще понимаете, почему он ведет себя так, а не иначе.
Перед тем как писать код, полезно остановиться и ответить на пару вопросов. Что на этом экране меняется со временем, а что остается неизменным? Где должен быть источник правды, то есть кто «владеет» данными? Что можно спокойно вычислить на лету, а что действительно нужно хранить как состояние? И какие внешние вещи придется синхронизировать: запросы, таймеры, подписки?
Простой пример - форма заказа. Новичок часто кладет в state все подряд: итоговую сумму, список ошибок, «валидно/невалидно», подсказки. Потом что-то не обновилось, и начинается «чинить ререндер». С более точной моделью вы храните только то, что ввел пользователь или что пришло с сервера, а остальное считаете из этих данных. Тогда ререндер перестает быть проблемой: он просто пересчитывает картинку.
Один из самых полезных способов думать про React (и тот, который хорошо совпадает с тем, как Dan Abramov объясняет основы) звучит так: компонент - это функция. На вход она получает данные (props), на выходе возвращает описание UI. Она не «собирает экран по шагам», а отвечает на вопрос: как должен выглядеть интерфейс при таких входных данных.
Отсюда вытекает важная часть модели: вы описываете результат, а не управляете процессом. Поэтому повторный рендер не означает «React заново создал все с нуля». Он заново вызывает ваши функции-компоненты, сравнивает новое описание с прошлым и меняет в реальном UI только то, что действительно отличается.
Представьте ProductCard({ name, price, isInCart }). Если изменился isInCart, это не обязано «трогать» заголовок или цену. Компонент просто снова возвращает описание UI, где кнопка стала «В корзине». Остальное может остаться прежним.
Чтобы компонент было легко объяснить (себе, коллегам и даже в запросе к генерации кода), держитесь простой структуры: сначала назовите входы и их смысл, потом отметьте, какие части UI от чего зависят, и отделите вычисления от отображения. Хорошее правило - «сначала считаю, потом рисую». И да, лучше несколько маленьких компонентов, чем один, который делает все сразу.
Если вы можете одной фразой ответить «что этот компонент показывает при таких данных», вы уже близко к правильному мышлению.
Еще одна полезная модель звучит почти слишком просто: данные идут вниз по дереву компонентов, а события и намерения пользователя поднимаются вверх. Она помогает не гадать, «кто поменял состояние», а видеть понятный маршрут: родитель хранит данные, ребенок показывает их и сообщает, что произошло.
Представьте фильтр товаров: сверху строка поиска и переключатель «в наличии», ниже список карточек. Поле ввода не должно само решать, как фильтровать данные. Оно показывает текущее значение и отправляет наверх событие: «пользователь ввел текст». Родитель получает это событие, обновляет состояние фильтра и передает вниз уже отфильтрованный список.
Где держать состояние обычно решается так: храните его там, где оно нужно максимальному числу компонентов, но не выше.
Когда сомневаетесь, полезно пройтись по четырем вопросам. Кто должен увидеть изменение: один компонент или несколько? Кто инициирует изменение: где происходит действие? Нужно ли сохранять это между экранами или достаточно локально? И можно ли вычислить это из других данных вместо хранения?
Если изменение важно двум соседним веткам, поднимите состояние в их общего родителя. Если важно только одному виджету, оставьте локально.
Длинная цепочка пропсов почти всегда сигнал: вы подняли состояние слишком высоко или передаете лишнее. Часто помогает разделение ролей: компонент выше держит данные и колбеки, а ниже рендерятся маленькие «чистые» компоненты, которые только отображают то, что им дали.
Состояние в React удобно представлять как память компонента: это то, что должно пережить повторный рендер. Если значение можно каждый раз заново получить из пропсов, из другого состояния или из текущих данных, ему обычно не место в state. Эта модель (ее часто подчеркивал Dan Abramov) резко снижает путаницу: меньше переменных, меньше расхождений.
Практическое правило - на каждую сущность должен быть один источник правды. Если у вас есть список товаров, то «выбранный товар» лучше хранить в одном виде (например, selectedId), а не дублировать как объект, как индекс и как отдельный флаг в разных местах. Дубликаты почти всегда приводят к багам: одно обновилось, другое нет.
Чтобы решить, что именно хранить, попробуйте описать состояние словами, до кода: что пользователь видит и что он выбирает. Например, в каталоге пользователь видит строку поиска, текущий фильтр и список результатов. А выбирает он категорию (id), сортировку (значение) и открытый товар (id). Это уже почти готовая схема состояния.
Хороший тест на понимание - контролируемые инпуты. Если поле ввода «живет своей жизнью», а вы отдельно храните query и отдельно считаете фильтрацию, легко получить рассинхрон. Когда value инпута берется из state, а изменения идут через onChange, вы точно знаете, откуда берется текущий текст и что будет после следующего рендера.
Значение стоит хранить в state, если оно: меняется со временем из-за действий пользователя, влияет на то, что отображается, и не может быть надежно вычислено из других данных.
С правильной моделью useEffect перестает быть «крючком для любых действий после рендера». Эффект нужен для синхронизации с внешним миром: сетью, таймерами, подписками, браузерным API, аналитикой. То есть с тем, что не описывается чистым возвратом JSX.
Частая ловушка - использовать эффект, чтобы «получить данные» и разложить их по состоянию, хотя эти данные уже можно вывести из текущих props или state. Тогда у вас появляется два источника правды (оригинал и копия), которые легко рассинхронизировать.
useEffect обычно не нужен, когда вы вычисляете значения для рендера, обрабатываете клики и ввод, или пытаетесь «склеить» одно состояние из другого. В этих случаях проще вычислять, а не хранить.
Зависимости эффекта - это не «все, что я использовал внутри», а список причин, почему синхронизация должна повториться. Смена причины - пересинхронизация. Нет причины - не повторяем.
Пример: вы показываете список задач и при смене фильтра надо обновить данные с сервера. Причина пересинхронизации - фильтр (и, возможно, идентификатор пользователя). При этом не нужно хранить в состоянии «отфильтрованный список», если он получается из tasks и filter.
Полезная модель React: каждый рендер видит свой снимок данных. Когда компонент отрисовался, значения props и state в этом рендере как будто зафиксированы. Обработчик клика, созданный в этом рендере, тоже «помнит» именно этот снимок.
Отсюда типичный сюрприз: вы жмете кнопку несколько раз, а в консоли будто «старое» значение. На деле это не магия и не ошибка React - вы просто смотрите на значения из конкретного рендера. Следующий рендер будет уже с новым снимком.
Если менять объект или массив прямо на месте, вы портите идею снимков. React и ваш код часто опираются на сравнение ссылок: «это тот же объект или новый?». При мутации ссылка остается прежней, и разные части приложения могут неожиданно увидеть измененные данные без явного перехода к новому снимку.
Бытовая аналогия: не правьте старый лист в общей папке, если другие уже на него сослались. Возьмите новый лист, перепишите нужное и положите как новую версию. Старый снимок остается честным, новый тоже.
На практике это значит: вместо items.push() или user.name = ... создавайте новые значения.
// плохо: мутация
items.push(newItem);
setItems(items);
// хорошо: новый массив
setItems(prev => [...prev, newItem]);
Когда обновление зависит от прошлого значения, используйте функциональную форму. Она читает самый свежий снимок, даже если несколько обновлений идут подряд:
setCount(c => c + 1);
setCount(c => c + 1);
Так состояние остается предсказуемым: каждый шаг создает новый снимок, и вам проще понимать, какой именно набор данных сейчас отображается.
Когда React рисует список, он пытается понять, где тот же самый элемент, а где новый. Для этого ему нужна идентичность. key - не украшение и не подсказка для сортировки. Это ярлык, по которому React связывает конкретную строку списка с ее состоянием: текстом в инпуте, фокусом, локальными хук-состояниями, анимациями.
Если ключ выбран плохо, React начинает «перепутывать людей» в очереди. Внешне список может выглядеть нормально, но состояние внезапно переезжает в другую строку.
Ключ-индекс (key={i}) безопасен только когда список никогда не меняет порядок и элементы не вставляются в середину. Иначе проблемы появляются быстро: вы вставили новый элемент сверху, и текст в инпуте оказался не в той строке; при удалении одной строки фокус прыгает на соседнюю; анимации «приклеиваются» к неправильному элементу.
Причина простая: индекс описывает позицию, а не сущность. Позиция меняется, а React думает, что это тот же элемент.
Хороший ключ должен быть стабильным и уникальным в рамках списка. Обычно это id из данных. Если id нет, добавьте его при создании элемента (на уровне данных), а не собирайте ключ из того, что меняется, например из текста, который редактируется.
Связь с предсказуемым состоянием прямая: состояние на строке списка привязано к key. Если ключ отражает реальную идентичность элемента, то и состояние остается «при своем хозяине» при сортировке, фильтрации и вставках.
Когда UI расползается, обычно проблема не в React, а в том, что вы начали писать компоненты до того, как договорились с собой о поведении. Эти модели помогают сначала прояснить смысл, а уже потом оформлять код.
Начните с человеческого описания. Например: «Пользователь вводит имя, выбирает роль, нажимает Сохранить, видит статус и возможную ошибку». Такое описание быстро показывает, что является состоянием, а что просто текстом на экране.
Дальше полезно идти одним и тем же маршрутом:
Мини пример: экран «Профиль». Состояние формы: name, role, isSaving, error. А вот «полное имя в заголовке» проще вычислить из name, не храня отдельно. Сохранение меняет isSaving и error, а успешный ответ обновляет данные и сбрасывает флаг.
Когда ломается модель React, вы это замечаете не по ошибке в консоли, а по странному поведению UI: «то обновилось, то нет», «инпут прыгает», «данные сами перезатираются». Почти всегда причина в нескольких повторяющихся привычках.
Самая частая - слишком много состояния. Если одно и то же значение хранится в двух местах (например, selectedId и selectedItem), они рано или поздно разъедутся. Держитесь правила «храню минимум, остальное вычисляю».
Вторая ловушка - использовать useEffect как универсальную заплатку. Эффект нужен для синхронизации с внешним миром, а не чтобы «доделать» логику рендера. Иначе появляются лишние запросы, гонки и циклы обновлений, которые сложно понять и отладить.
Третья проблема - мутации объектов и массивов. Если вы меняете массив «на месте» (например, сортируете его или добавляете элементы), React может не увидеть изменения там, где вы ожидаете, или вы получите побочные эффекты в другом компоненте. Проще держать железное правило: новое значение - новый объект.
И еще один болезненный пункт - ключи в списках. Ключ должен описывать идентичность элемента, а не его позицию. Иначе при вставке или сортировке «переедут» значения инпутов и потеряется фокус.
Наконец, раннее усложнение: контекст, глобальный стор, абстракции «на будущее». Пока проблема локальная, держите состояние рядом с местом использования.
Перед ревью или отправкой в тестирование полезно на минуту остановиться и пройтись по нескольким вопросам. Эти проверки часто ловят ошибки, из-за которых UI начинает жить своей жизнью.
Каждая переменная состояния должна отвечать на вопрос: "что пользователь видит сейчас?" Если вы не можете объяснить это одной фразой, возможно, это не state.
Хороший признак: state хранит выбранную вкладку, введенный текст, включенный фильтр, открыта ли модалка. Плохой признак: state хранит «готовый список для рендера», который легко вычислить из других данных.
Проверьте, нет ли двух мест, которые описывают одно и то же. Например, и isLoggedIn, и user != null одновременно. Или items и filteredItems в state.
Если значение можно вычислить из props, state и простых вычислений, лучше вычислять. Тогда меньше рассинхронизаций и меньше «почему это не обновилось».
useEffect должен синхронизировать компонент с внешним миром: запрос, подписка, таймер, запись в storage. Если эффект просто «доправляет» state, это часто знак, что модель данных выбрана неверно.
Быстрая проверка зависимостей:
В списках ключи берутся из данных (id), а не из индекса. Индекс подходит только для статичных списков без вставок, удалений и сортировки.
Если обновляете массивы и объекты, делайте это без изменения исходных значений. Мутации часто ломают сравнения и приводят к «частично обновилось».
Небольшой ориентир: после setState вы должны быть уверены, что создали новый объект или массив на каждом измененном уровне, а неизмененные части остались теми же ссылками.
Представьте простой экран: список клиентов, строка фильтра, карточка выбранного клиента и форма редактирования. Такой пример хорошо проверяет мышление: что является данными, что является действием, и где живет состояние.
Дерево компонентов можно набросать так: App -> List -> Item -> Details -> Form. Важно не название, а границы ответственности. List показывает коллекцию, Item только отображает строку, Details показывает выбранного клиента, а Form отвечает за ввод.
Состояние стоит разложить по уровню, где оно нужно нескольким детям:
App (нужен и списку, и, возможно, заголовку или счетчику)App или Details (если выбор влияет на список, лучше в App)Form (локально, пока пользователь печатает)FormСобытия простые: клик по Item меняет выбранный id; ввод в поле меняет черновик; «Сохранить» запускает сохранение; «Отмена» сбрасывает черновик к данным выбранного клиента.
Эффекты нужны только для синхронизации с внешним миром: загрузить список при старте, отправить изменения на сервер при сохранении, показать уведомление после успеха или ошибки. Все остальное - обычные вычисления в рендере, например отфильтрованный список.
Чтобы React оставался понятным, держитесь одной идеи: вы не «собираете магию», вы описываете интерфейс как результат состояния.
Начните с формулировки задачи короткими сообщениями, как будто объясняете экран коллеге. Заранее назовите: какие есть экраны, какие действия делает пользователь, какие правила у данных (что обязательно, что может быть пустым, что должно быть уникальным). Чем точнее вход, тем реже потом приходится «чинить» поведение.
Дальше набросайте каркас компонентов и проверяйте его по тем же вопросам: где источник правды, что является пропсом, а что локальным состоянием, какие значения можно вычислять, а не хранить. Если что-то выглядит подозрительно, формулируйте правку не как «поправь код», а как «это состояние должно жить выше», «это значение вычисляется из X и Y», «эффект нужен только для синхронизации».
Рабочий ритм помогает не раздувать сложность:
Если вы собираете приложения через чат, полезно, чтобы платформа умела поддерживать планирование до кода и безопасные откаты. Например, TakProsto (takprosto.ai) позволяет генерировать веб, серверные и мобильные приложения из диалога, а снапшоты и откат помогают смело пробовать идеи и возвращаться к рабочему варианту, если эксперимент не удался.
Когда поведение устраивает, завершайте работу практично: подготовьте деплой и хостинг, подключите домен, а исходники экспортируйте, чтобы сохранить независимость и возможность доработок в привычной среде.
Думайте так: компонент — это функция. На входе props и текущий state, на выходе — описание интерфейса. Вы не «обновляете DOM вручную», а просто задаете, как UI должен выглядеть при этих данных.
Практика: сначала вычислите нужные значения из данных, потом возвращайте JSX (правило «сначала считаю, потом рисую»).
Рендер — это обычный вызов функции-компонента, чтобы получить новое описание UI. Это не команда «перерисуй всё», а пересчет результата.
Если у вас «что-то не обновилось», чаще всего причина не в рендере, а в неверно выбранном состоянии, мутациях или дублях данных.
Храните в state только то, что:
Всё остальное лучше вычислять в рендере. Например, итоговую сумму и «валидно/невалидно» часто можно получить из введенных полей.
Главное правило — один источник правды на одну сущность. Если вы храните одно и то же значение в двух местах (например, selectedId и selectedItem), они рано или поздно разъедутся.
Выберите один «главный» формат (обычно id или оригинальные данные с сервера), а производные вещи считайте на лету.
Обычно так: данные идут вниз по дереву компонентов, а события (намерения пользователя) поднимаются вверх.
Практика:
props;onChange, onClick), а не меняет состояние «сам по себе».Держите состояние там, где оно нужно максимальному числу компонентов, но не выше.
Быстрая проверка:
useEffect нужен для синхронизации с внешним миром: запросы, таймеры, подписки, браузерные API.
Не используйте эффект, чтобы «склеить» одно состояние из другого или подготовить данные для рендера — это почти всегда приводит к дублям и рассинхронизациям. Сначала попробуйте вычислить значение прямо в рендере.
Зависимости — это причины, по которым синхронизацию надо повторить. Если причина поменялась — пересинхронизируемся, если нет — эффект не должен запускаться.
Практика:
Каждый рендер видит свой «снимок» props и state. Обработчики событий тоже замыкаются на значения из конкретного рендера, поэтому в логах можно увидеть «старые» данные — это нормально.
Чтобы обновления были предсказуемыми:
setState(prev => ...), когда новое значение зависит от предыдущего.key — это идентичность элемента списка, а не его позиция. Неправильный ключ приводит к тому, что состояние (например, текст в инпуте или фокус) «переезжает» в другую строку.
Практика:
id из данных;key={index} используйте только для статичных списков без сортировки, вставок и удалений;