Практический курс Frontend-разработчика: JavaScript, React, TypeScript и современный state/data stack

Практико-ориентированный курс, где основное обучение строится вокруг разработки реального приложения на React + TypeScript. Вы последовательно освоите JavaScript, работу с формами, управление состоянием, серверные данные и архитектуру фронтенда, закрепляя каждую тему задачами и мини-проектами.

1. Инструменты, Git и старт проекта: Vite, ESLint, архитектура

Инструменты, Git и старт проекта: Vite, ESLint, архитектура

Зачем это нужно

В реальной работе фронтенд-разработка почти всегда начинается не с написания компонентов, а с настройки среды:

  • чтобы проект одинаково запускался у всех в команде
  • чтобы ошибки ловились до ревью и продакшена
  • чтобы кодовая база росла без хаоса в папках
  • В этой статье мы соберём фундамент под весь курс: создадим проект на Vite (React + TypeScript), подключим ESLint, заведём Git-репозиторий и заложим понятную архитектуру папок под будущие темы (state-менеджмент, TanStack Query, формы).

    Что установить перед стартом

    Нам нужны инструменты, которые одинаково хорошо работают на Windows, macOS и Linux.

  • Node.js (LTS)
  • Git
  • Менеджер пакетов (рекомендуется pnpm, можно npm)
  • Ссылки на официальные источники:

  • Node.js
  • Git
  • pnpm
  • Проверка установки

    В терминале выполните:

    Если pnpm не установлен:

    Git: минимальный рабочий процесс

    Git — это система контроля версий: она хранит историю изменений и позволяет безопасно экспериментировать.

    Создаём репозиторий

  • Создайте папку проекта и перейдите в неё.
  • Инициализируйте Git.
  • Базовые правила коммитов

    Коммит — это логически завершённый набор изменений. Хорошая привычка для обучения и для работы:

  • коммитить небольшими порциями
  • писать понятное сообщение
  • не смешивать в одном коммите несвязанные правки
  • Примеры сообщений:

  • init project with Vite
  • add eslint config
  • setup app folder structure
  • Что обязательно добавить в .gitignore

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

  • node_modules
  • dist
  • .env (если используете)
  • Старт проекта на Vite (React + TypeScript)

    Vite — современный сборщик и dev-сервер. Он быстро стартует, быстро обновляет изменения и даёт простой путь к сборке.

    Официальная документация:

  • Vite
  • React
  • TypeScript
  • Создание проекта

    Внутри папки репозитория выполните:

    Запуск:

    Полезные команды Vite-проекта:

  • pnpm dev — запуск dev-сервера
  • pnpm build — сборка в папку dist
  • pnpm preview — локальный просмотр production-сборки
  • Первый коммит

    После успешного запуска сделайте коммит:

    Настройка TypeScript: режим строгости и ожидания

    TypeScript в шаблоне уже подключён. Наша цель — не усложнить конфиг, а договориться о принципах:

  • типы помогают ловить ошибки в рантайме ещё на этапе написания кода
  • строгие настройки лучше включать сразу, пока проект маленький
  • Проверьте tsconfig.json. В большинстве случаев полезно иметь включённым strict: true. Если в вашем шаблоне он отключён — включите и зафиксируйте изменения отдельным коммитом.

    ESLint: защита качества кода на входе

    ESLint — это линтер: инструмент, который анализирует код и подсвечивает потенциальные ошибки и проблемные паттерны.

    Официальная документация:

  • ESLint
  • Что именно будет делать ESLint в нашем проекте

  • ловить ошибки импорта и неиспользуемых переменных
  • подсказывать проблемы с хуками React
  • удерживать код в едином стиле (частично)
  • Важно различать:

  • линтинг (ESLint) — про ошибки и правила кода
  • форматирование (обычно Prettier) — про пробелы, переносы строк, оформление
  • В этом курсе мы начинаем с ESLint как обязательной базы.

    Установка и конфигурация ESLint для React + TS

    Vite-шаблон часто уже содержит ESLint-зависимости, но конфигурация может отличаться. Приведём рабочий вариант на современном формате ESLint Flat Config.

    Установите зависимости:

    Создайте файл eslint.config.js в корне проекта:

    Добавьте скрипт в package.json:

    Запуск линтера:

    Сделайте коммит:

    Алиасы импортов: меньше ../../.., больше читаемости

    Когда проект растёт, относительные импорты становятся хрупкими. Например, перенос файла ломает половину импортов.

    Мы настроим алиас @ на папку src, чтобы писать так:

  • import { Button } from "@/shared/ui/Button";
  • Настройка Vite

    Откройте vite.config.ts и добавьте resolve.alias:

    Настройка TypeScript

    Откройте tsconfig.json (или tsconfig.app.json — зависит от шаблона) и добавьте:

    Проверьте, что IDE перестала ругаться на импорты с @.

    Архитектура проекта: как разложить код по папкам

    Архитектура в рамках фронтенд-проекта — это договорённость, куда складывать код, чтобы:

  • его было легко находить
  • модули были слабо связаны
  • проект было проще расширять
  • Мы используем простую слоистую структуру, совместимую с ростом проекта и будущими темами (state, запросы, формы).

    !Дерево папок и назначение слоёв

    Слои и ответственность

  • app — сборка приложения: роутинг, провайдеры, инициализация, глобальные стили
  • pages — страницы (то, что обычно связано с маршрутом)
  • features — пользовательские действия и сценарии (например, логин, поиск, фильтры)
  • entities — сущности предметной области (например, user, project, task)
  • shared — переиспользуемые вещи без привязки к домену (UI, утилиты, базовые хуки)
  • Рекомендуемая структура src

    | Папка | Что внутри | Примеры | |---|---|---| | src/app | входные точки и провайдеры | App.tsx, main.tsx, провайдеры стора/запросов | | src/pages | страницы | HomePage, ProfilePage | | src/features | сценарии пользователя | auth/login, tasks/filter | | src/entities | доменные сущности | user, task | | src/shared | базовые переиспользуемые модули | ui, lib, api |

    Практическое правило: близость к месту использования

  • Если компонент нужен только в одной фиче — держите его внутри этой фичи.
  • Если компонент стал общим для разных мест — поднимайте его в shared.
  • Это помогает не превращать shared в “свалку всего на свете”.

    Создаём папки и минимальные файлы

    Создайте структуру:

    Минимальная точка входа уже есть (src/main.tsx). На этом этапе достаточно зафиксировать структуру коммитом:

    Подготовка к будущим темам курса

    Чтобы следующие темы легли без переделок, заранее держите в голове:

  • State-менеджмент (Redux/Zustand) обычно подключается на уровне app через провайдер/инициализацию
  • TanStack Query тоже чаще подключается в app (QueryClientProvider), а запросы удобно группировать рядом с сущностями в entities или в shared/api
  • React Hook Form чаще живёт внутри features (конкретные формы) и использует переиспользуемые поля из shared/ui
  • Итог

    Вы создали базу, на которой будем строить весь курс:

  • подготовили окружение (Node, Git, менеджер пакетов)
  • создали проект на Vite (React + TypeScript)
  • настроили ESLint под React и хуки
  • добавили алиасы импортов @
  • заложили архитектуру папок, которая выдержит рост проекта
  • Дальше будем развивать приложение функционально, постоянно опираясь на эти инструменты: Git для аккуратной истории, линтер для качества, архитектуру для масштаба.

    2. JavaScript для UI: события, async/await, модули, работа с API

    JavaScript для UI: события, async/await, модули, работа с API

    Зачем это нужно

    Даже если вы пишете на React, основу поведения приложения задаёт JavaScript:

  • события связывают действия пользователя (клик, ввод, отправка формы) и логику
  • async/await управляет асинхронностью (запросы к серверу, таймеры, ожидание данных)
  • модули помогают держать код в порядке (разделение по слоям shared, entities, features)
  • работа с API превращает UI из “статичной верстки” в приложение с реальными данными
  • В предыдущей статье вы настроили Vite + TypeScript + ESLint, алиас @ и базовую архитектуру src. Здесь мы начнём наполнять эту структуру практическим кодом: сделаем небольшой модуль API и подключим его к UI через события и async/await.

    Мини-план практики на статью

    Мы реализуем простой сценарий:

  • кнопка Load todos загружает список задач с публичного API
  • показываем состояния loading и error
  • добавляем поле фильтрации (событие onChange)
  • выносим сетевую логику в модуль shared/api
  • Источник данных: JSONPlaceholder (публичный учебный REST API).

    События в UI

    Событие — это сигнал от браузера, что что-то произошло: пользователь кликнул, ввёл текст, отправил форму, прокрутил страницу.

    События в браузере

    В чистом браузерном JavaScript вы подписываетесь на событие через addEventListener:

    Важные идеи:

  • обработчик — это функция, которую вызовет браузер
  • многие события “всплывают” вверх по DOM-дереву (это используется в делегировании)
  • подписки нужно уметь снимать, если элемент/логика “живёт” не всегда
  • Ссылка на справочник событий: MDN: Event

    События в React

    В React вы обычно не вызываете addEventListener для кликов/инпутов напрямую. Вместо этого вы передаёте обработчик как проп:

    Практический смысл тот же: пользовательское действие запускает ваш код, но управление подписками делает React.

    Частые случаи: preventDefault и stopPropagation

  • event.preventDefault() отменяет стандартное действие браузера
  • - пример: отправка формы с перезагрузкой страницы
  • event.stopPropagation() останавливает всплытие события
  • - пример: клик по кнопке внутри карточки не должен запускать клик по всей карточке

    Пример с формой в React (не перезагружать страницу):

    События ввода: фильтрация по onChange

    Фильтрация “на лету” обычно делается через событие изменения поля:

    Если фильтрация запускает запрос к API на каждый ввод, вы обычно добавляете debounce (искусственную задержку), но в этом курсе позже мы решим такие задачи более системно через TanStack Query.

    Асинхронность в UI и async/await

    В UI вы почти всегда ждёте что-то “не сразу”:

  • ответ сервера
  • загрузку файла
  • таймер
  • В JavaScript это оформляется через Promise (объект, представляющий результат, который появится позже). async/await — удобный синтаксис для работы с Promise.

    Справочник: MDN: async function

    Базовый шаблон: загрузка, успех, ошибка, завершение

    Для UI важны четыре состояния:

  • старт загрузки (показать спиннер, заблокировать кнопку)
  • успех (показать данные)
  • ошибка (показать сообщение)
  • завершение (снять флаг загрузки)
  • Шаблон:

    Параллельные запросы: Promise.all

    Если нужно получить несколько ресурсов независимо, их можно запрашивать параллельно:

    Важно: если любой из промисов завершится ошибкой, Promise.all тоже завершится ошибкой.

    Справочник: MDN: Promise.all

    Отмена запроса: AbortController

    В UI часто бывает ситуация:

  • пользователь ушёл со страницы
  • пользователь быстро меняет фильтр
  • старый запрос уже не нужен
  • Для этого у fetch есть механизм отмены через AbortController.

    Справочник: MDN: AbortController

    !Диаграмма, показывающая состояния загрузки и отмену запроса

    Модули: как организовать код, чтобы он рос без хаоса

    Модуль в JavaScript — файл с явными экспортами и импортами. В Vite-проектах используются ES-модули “из коробки”.

    Справочник: MDN: JavaScript modules

    Named export и import

    Default export

    Практическое правило в командной разработке:

  • чаще используйте named exports (проще переименовывать и рефакторить)
  • default export оставляйте для случаев, когда модуль “про одно главное”
  • Индексные файлы (“barrel”)

    Иногда удобно реэкспортировать набор функций из index.ts, чтобы импорт выглядел короче.

    Важно: не злоупотребляйте barrel-файлами везде подряд, иначе сложнее отследить зависимости.

    Работа с API: fetch, обработка ошибок, типизация

    fetch и JSON

    fetch возвращает Promise с объектом Response. Чтобы получить JSON, обычно вызывают response.json().

    Справочник: MDN: fetch

    Ключевой момент: fetch не считает HTTP 404/500 ошибкой промиса. Промис “упадёт” только при сетевой ошибке или отмене. Поэтому проверка response.ok — обязательная часть.

    Создадим базовую обёртку fetchJson

    Сделаем модуль в shared, чтобы использовать его в разных сущностях.

    Создайте файл src/shared/api/fetchJson.ts:

    Почему так:

  • shared/api содержит общую инфраструктуру запросов
  • entities/todo/api содержит доменные запросы “про todo”
  • Практика: подключаем загрузку данных к UI

    Ниже пример страницы, которая использует события и async/await для загрузки данных.

    Создайте файл src/pages/TodosPage/TodosPage.tsx:

    Подключите страницу в src/app/App.tsx (временно, без роутинга):

    Что вы только что применили:

  • события onClick, onChange связали UI и логику
  • async/await реализовал запрос и обработку ошибок
  • модули разнесли ответственность: shared/apientities/todo/apipages
  • API-логика переиспользуема и готова к росту
  • Типичные ошибки и как их избегать

  • “Данные не грузятся, но ошибок нет”
  • - проверьте, что вы обработали !response.ok (HTTP 404/500)
  • “Loading навсегда”
  • - убедитесь, что setLoading(false) находится в finally
  • “Старый запрос перетирает новые данные”
  • - используйте отмену через AbortController или контроль актуальности запроса
  • “Импорты превратились в ../../..
  • - используйте алиас @ и держите слои (shared, entities, features, pages) в порядке

    Как это связано со следующим стеком курса

    То, что вы сделали вручную (loading/error, кеширование “в голове”, отмена запросов), позже будет решаться инструментами уровня приложения:

  • TanStack Query возьмёт на себя загрузку/кеш/повторы/инвалидации
  • Zustand или Redux помогут согласованно хранить клиентское состояние
  • React Hook Form упростит формы и валидацию
  • Но фундамент остаётся тем же: события запускают логику, async/await управляет асинхронностью, модули и архитектура удерживают кодовую базу в порядке.

    3. TypeScript на практике: типизация компонентов, API и утилит

    TypeScript на практике: типизация компонентов, API и утилит

    Зачем это нужно

    В прошлых статьях вы:

  • Настроили проект (Vite, ESLint, алиас @, базовая архитектура слоёв).
  • Написали UI, который грузит данные через fetch, обрабатывает loading/error, отменяет запрос через AbortController и раскладывает код по shared и entities.
  • Теперь задача — сделать этот код надёжнее и удобнее в поддержке с помощью TypeScript:

  • типизировать React-компоненты и обработчики событий
  • типизировать API-слой (общая обёртка и доменные запросы)
  • писать утилиты так, чтобы TypeScript помогал, а не мешал
  • Ссылки на первоисточники:

  • TypeScript Handbook
  • React и TypeScript
  • Важная идея: TypeScript проверяет код, но не делает данные “правильными”

    TypeScript работает на этапе разработки и сборки. Он:

  • находит ошибки типов до запуска приложения
  • подсказывает автодополнение и безопасные рефакторинги
  • Но он не валидирует данные, которые пришли по сети. Если сервер прислал неожиданный JSON, TypeScript не “починит” его автоматически.

    Практическое правило:

  • типы описывают контракт, которого мы ожидаем
  • для критичных данных добавляйте runtime-проверки (позже это часто делают через схемы вроде Zod, но в этой статье обойдёмся базовыми приёмами)
  • Типизация React-компонентов: props, children, события

    Типизируем props без React.FC

    На практике удобнее типизировать параметры функции, а не использовать React.FC.

    Пример: выделим кнопку загрузки в shared/ui.

    Создайте src/shared/ui/LoadButton/LoadButton.tsx:

    Что даёт типизация:

  • нельзя забыть передать onClick
  • нельзя передать loading="true" строкой
  • сигнатура onClick: () => void гарантирует, что обработчик не ждёт аргументы, которые мы не передадим
  • Типизируем children, когда нужно

    Если компонент-обёртка принимает вложенный контент, типизируйте children явно.

    Типизируем события React: ChangeEvent и FormEvent

    Частая ошибка новичков: типизировать DOM-события вместо React-событий.

    Правильно:

    Для формы:

    Практическое правило:

  • в React используйте типы из react (ChangeEvent, FormEvent, MouseEvent)
  • в e.currentTarget обычно лежит элемент, на котором висит обработчик
  • useState и типы: когда полагаться на вывод типов, а когда задавать явно

    TypeScript часто сам выводит тип состояния по начальному значению.

    Здесь тип будет boolean.

    Но если начальное значение null или пустой массив, лучше подсказать тип.

    Почему это важно:

  • без <Todo[]> пустой массив может привести к слабым подсказкам и лишним приведениями типов
  • string | null заставляет вас обработать оба случая в рендеринге
  • Типизация API: обобщения (generics), unknown, единый контракт ошибок

    Улучшаем fetchJson: возвращаем Promise<T> и аккуратно работаем с unknown

    У вас уже есть fetchJson<T>. Доработаем его так, чтобы:

  • body был типа unknown, чтобы случайно не передать, например, функцию.
  • ошибка имела понятный формат.
  • Обновите src/shared/api/fetchJson.ts:

    Здесь Todo[] — ожидаемая форма данных.

    > Важно: это ожидание, а не гарантия. Если сервер вернёт другой JSON, TypeScript этого не узнает автоматически.

    Мини-проверка данных без библиотек: type guard

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

    Создайте src/entities/todo/model/guards.ts:

    И пример использования (опционально, если хотите усилить надёжность прямо сейчас):

    Так вы честно признаёте, что сеть возвращает unknown, и превращаете это в Todo[] только после проверки.

    Практика: делаем состояние загрузки типобезопасным через union

    Сейчас на странице три отдельных стейта: loading, error, todos. Это нормально, но иногда приводит к противоречиям (например, loading=true и error не null).

    Сделаем единый LoadState как дискриминирующее объединение.

    !Диаграмма показывает, какие состояния может иметь загрузка и какие данные доступны в каждом

    Создайте src/shared/lib/loadState.ts:

    Почему это полезно:

  • если status === "success", TypeScript гарантирует, что data существует
  • вы не сможете случайно прочитать data в состоянии ошибки
  • Вспомогательная утилита assertNever для исчерпывающих switch

    Создайте src/shared/lib/assertNever.ts:

    Что стало лучше:

  • состояние страницы описано одним типом LoadState<Todo[]>
  • логика рендера зависит от state.status, а не от набора флагов
  • обработка ошибок различает AbortError, HttpError и прочие Error
  • assertNever страхует от “забыли обработать новый статус” при расширении
  • Типизация утилит: практичные паттерны, которые пригодятся дальше

    debounce с сохранением типов аргументов

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

    Создайте src/shared/lib/debounce.ts:

    Что здесь важно:

  • TArgs extends unknown[] позволяет сохранить типы аргументов исходной функции
  • возвращаемая функция принимает те же аргументы, что и fn
  • as const для стабильных литералов

    Если вам нужен набор статусов, ключей или табов, используйте as const.

    Так TodoFilter станет union-типом: "all" | "active" | "completed".

    Итог

    Вы сделали TypeScript “рабочим инструментом”, а не формальностью:

  • типизировали компоненты, children и события React
  • усилили API-слой: generics, unknown, единый класс HTTP-ошибки
  • научились писать type guards для минимальной проверки данных
  • описали состояние загрузки через union-типы и получили более безопасный UI
  • добавили базовые утилиты (assertNever, типизированный debounce)
  • Дальше этот фундамент напрямую пригодится:

  • в React Hook Form вы будете типизировать форму и данные сабмита
  • в TanStack Query вы будете типизировать queryFn и результат запроса
  • в Redux или Zustand вы будете типизировать состояние и экшены
  • 4. React основы через задачи: компоненты, хуки, роутинг, UI-паттерны

    React основы через задачи: компоненты, хуки, роутинг, UI-паттерны

    Зачем эта статья

    В прошлых статьях вы собрали фундамент проекта (Vite, ESLint, алиас @, архитектура слоёв), написали загрузку данных через fetch, а затем усилили код TypeScript-типами и сделали состояние загрузки более безопасным через LoadState<T>.

    Теперь мы переходим к React как к инструменту сборки UI:

  • компоненты как способ разбивать интерфейс на независимые части
  • хуки как механизм состояния, эффектов и оптимизаций
  • роутинг как скелет приложения со страницами и вложенными маршрутами
  • UI-паттерны как практические правила, чтобы код не превращался в "комок"
  • Формат статьи практический: мы будем решать задачи и постепенно превращать ваш проект из одной страницы в небольшое приложение.

    Полезные первоисточники:

  • React документация
  • Хук useState
  • Хук useEffect
  • Хук useMemo
  • Хук useCallback
  • React Router документация
  • Что мы построим

    Мини-приложение с двумя страницами и деталкой:

  • главная страница
  • список todos с фильтром
  • страница деталей todo по id
  • страница 404
  • !Карта маршрутов и где живут компоненты в архитектуре

    Задача: добавить роутинг

    Установка React Router

    Установите пакет:

    Подключение роутера в точке входа

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

    Обновите src/main.tsx:

    Почему это правильно:

  • BrowserRouter должен быть выше всех компонентов, которые используют Link, useParams, Routes
  • это часть сборки приложения, а не конкретной страницы
  • Добавим layout приложения

    Layout нужен, чтобы не копировать шапку и контейнер на каждой странице.

    Создайте src/app/layout/AppLayout.tsx:

    Ключевая деталь: Outlet это "место", куда React Router будет вставлять компонент дочернего маршрута.

    Добавим страницы

    Создайте src/pages/HomePage/HomePage.tsx:

    Создайте src/pages/NotFoundPage/NotFoundPage.tsx:

    Настроим маршруты в App

    Обновите src/app/App.tsx:

    Что здесь важно:

  • layout задаётся как маршрут без path, чтобы применяться ко всем дочерним
  • :id это параметр маршрута (строка), которую мы будем читать через useParams
  • Компоненты: как думать о UI в React

    Компонент это функция, которая:

  • принимает props
  • возвращает описание UI
  • Практические правила:

  • компонент должен делать одну понятную вещь
  • чем меньше "знаний" о внешнем мире внутри компонента, тем проще тестировать и переиспользовать
  • данные идут сверху вниз через props
  • Задача: вынести список и элемент списка

    Сейчас TodosPage может быть перегружен разметкой. Разобьём UI на компоненты.

    Создайте src/entities/todo/ui/TodoItem.tsx:

    Реализуем TodoDetailsPage

    Создайте src/pages/TodoDetailsPage/TodoDetailsPage.tsx:

    Что вы отработали:

  • useParams всегда возвращает строки, поэтому мы явно приводим id к числу
  • useEffect запускает загрузку при изменении id
  • AbortController отменяет запрос при уходе со страницы или смене id
  • LoadState<T> задаёт понятные состояния UI без противоречий
  • UI-паттерны, которые стоит закрепить на старте

    Паттерн: контролируемый ввод

    Контролируемый компонент это когда значение поля хранится в state, а UI получает value из этого состояния.

    Признаки:

  • у input есть value={...}
  • у input есть onChange, который обновляет состояние
  • Это удобно, потому что:

  • UI всегда отражает состояние React
  • валидацию и синхронизацию проще делать позже (в том числе через React Hook Form)
  • Паттерн: разделяйте контейнер и представление

    Практический смысл:

  • "умный" компонент знает про API, загрузку и состояния
  • "глупый" компонент получает данные через props и просто рисует
  • В нашей архитектуре обычно получается так:

  • pages и часть features чаще "умные"
  • shared/ui и entities/*/ui чаще "глупые"
  • !Поток данных и событий в UI без глобального state

    Паттерн: мемоизация по делу

    useMemo и useCallback не делают приложение "быстрее автоматически". Они полезны, когда:

  • вычисления дорогие
  • вы передаёте функции вниз в компоненты, которые оптимизированы через React.memo
  • В вашем текущем примере useMemo для фильтрации уместен как учебная практика, потому что он подчёркивает идею "derived state".

    Задача: привести TodosPage к "страничному" уровню и собрать UI из компонентов

    Ниже пример, как может выглядеть TodosPage, если она использует компоненты сущности todo.

    Обновите src/pages/TodosPage/TodosPage.tsx:

    Что здесь закрепляется:

  • страница собирает сценарий, а не рисует каждую мелочь
  • список и элемент списка переиспользуемы
  • обработчик onLoad стабилен по ссылке через useCallback и готов для дальнейшей оптимизации
  • Куда это ведёт дальше по курсу

    После этой статьи у вас есть каркас приложения и основные привычки React-разработки:

  • приложение живёт в маршрутах и страницах, а не в одной большой компоненте
  • сетевые данные и состояния загрузки оформлены типобезопасно
  • UI разбит на компоненты с понятной ответственностью
  • Дальше эти решения станут основой для современного state/data stack:

  • TanStack Query возьмёт на себя загрузку, кеш, повторные запросы, отмену и состояние loading/error
  • Zustand или Redux помогут хранить клиентское состояние, которое нужно многим экранам
  • React Hook Form заменит ручной контроль форм там, где появляются правила валидации и сложные сценарии
  • 5. Формы и валидация: React Hook Form, схемы, UX ошибок

    Формы и валидация: React Hook Form, схемы, UX ошибок

    Зачем это нужно

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

  • надёжно (валидация, типы, обработка ошибок сервера)
  • удобно для пользователя (понятные сообщения, корректный момент показа ошибок)
  • без лишних перерендеров и ручной рутины
  • В прошлых статьях вы:

  • настроили архитектуру и слои (shared, entities, features, pages)
  • сделали загрузку данных и обработку loading/error
  • добавили роутинг и разбили UI на компоненты
  • Теперь мы добавим полноценный сценарий с формой: создание todo через React Hook Form с типами, схемой валидации и хорошим UX ошибок.

    Официальные источники:

  • React Hook Form
  • Zod
  • @hookform/resolvers
  • Что мы построим

    Мы добавим страницу создания задачи todo:

  • маршрут /todos/new
  • форма с полями title, userId, completed
  • валидация через схему Zod
  • показ ошибок под полями и общей ошибки от сервера
  • UX-поведение: ошибки показываются “в правильный момент”, кнопка сабмита блокируется при отправке, фокус прыгает на первое невалидное поле
  • !Диаграмма показывает, как значения проходят через React Hook Form и схему, а ошибки возвращаются в UI

    Установка зависимостей

    Установите библиотеки:

    Зачем они нужны:

  • react-hook-form управляет состоянием формы и событиями
  • zod описывает схему данных и правила
  • @hookform/resolvers связывает схему и React Hook Form
  • Где хранить код по нашей архитектуре

    Практическое правило:

  • entities/todo хранит типы и API сущности
  • features/todoCreate хранит форму и схему, потому что это пользовательский сценарий “создать todo”
  • pages/TodoCreatePage подключает фичу к маршруту
  • Создайте папки:

    Обновим API сущности: создание todo

    В прошлых статьях entities/todo/api/todoApi.ts уже содержал getTodos и getTodoById. Добавим createTodo.

    Обновите src/entities/todo/api/todoApi.ts:

    Важно понимать ограничение учебного API JSONPlaceholder:

  • запрос POST вернётся “успешно”, но данные на сервере реально не сохранятся
  • для обучения это подходит: мы отрабатываем форму, валидацию, обработку ошибок
  • Схема валидации: Zod как единый контракт формы

    Схема решает две задачи одновременно:

  • валидация значений
  • типизация данных формы
  • Создайте src/features/todoCreate/model/schema.ts:

    Здесь ключевой момент: z.coerce.number().

  • браузерный input почти всегда отдаёт строку
  • z.coerce.number() пытается привести строку к числу
  • если приведение невозможно, схема вернёт ошибку
  • А z.infer<typeof todoCreateSchema> даёт тип строго из схемы, чтобы не разводить два источника правды.

    React Hook Form: базовые идеи, которые реально важны

    Почему React Hook Form обычно быстрее “ручного useState на каждое поле”

    React Hook Form опирается на идею неуправляемых инпутов (uncontrolled) и подписок на изменения.

    Практический эффект:

  • вы не обязаны хранить value каждого поля в useState
  • форма ререндерится меньше
  • вы получаете errors, isSubmitting, isDirty, isValid без ручной логики
  • Что делает register

    register("fieldName") связывает конкретный input с формой:

  • React Hook Form начинает отслеживать значение поля
  • подключаются обработчики onChange, onBlur, ref
  • по имени поля строятся values и errors
  • UI формы: компонент фичи TodoCreateForm

    Создайте src/features/todoCreate/ui/TodoCreateForm.tsx:

    Что здесь важно.

  • resolver: zodResolver(todoCreateSchema) связывает правила схемы с формой
  • TodoCreateValues берётся из схемы, значит сабмит всегда типобезопасный
  • errors.root.server это удобное место для “общей ошибки” (не конкретного поля)
  • isSubmitting делает блокировку кнопки сабмита без ручного loading
  • submitCount помогает сделать UX: не показывать ошибки до первой попытки сабмита
  • UX ошибок: как сделать так, чтобы форма не бесила пользователя

    Когда показывать ошибки

    Практичные стратегии:

  • показывать ошибки после первой попытки отправки и дальше обновлять на вводе
  • показывать ошибки по blur, если форма “длинная” и пользователь двигается по полям
  • В React Hook Form это настраивается:

  • mode: "onSubmit" | "onBlur" | "onChange"
  • reValidateMode: "onChange" | "onBlur"
  • Подход из примера выше обычно воспринимается хорошо:

  • до первого сабмита ошибок не видно
  • после сабмита ошибки обновляются “сразу” при исправлении
  • Фокус на первое невалидное поле

    Это маленькая деталь, которая сильно улучшает UX:

  • пользователь нажал Create
  • форма не отправилась
  • фокус автоматически оказался там, где ошибка
  • Мы сделали это через handleSubmit(onValid, onInvalid) и setFocus.

    Сообщения должны быть конкретными

    Плохое сообщение: Неверное значение.

    Хорошие сообщения:

  • Название должно быть не короче 3 символов
  • userId должен быть положительным
  • Схема валидации удобна тем, что сообщения лежат рядом с правилами.

    Подключаем страницу и маршрут

    Создайте src/pages/TodoCreatePage/TodoCreatePage.tsx:

    Добавьте маршрут в src/app/App.tsx:

    И добавьте ссылку в src/app/layout/AppLayout.tsx, чтобы можно было перейти на страницу:

    Кастомные поля и Controller: когда register недостаточно

    register отлично работает с обычными input, textarea, select.

    Но иногда поле не отдаёт значение через стандартные event.target.value, например:

  • кастомный селект
  • компонент даты
  • UI-компонент, который хранит значение внутри себя
  • Тогда используется Controller.

    Пример (не обязательно внедрять прямо сейчас, но важно понимать идею):

    Итог

    Вы добавили в приложение полноценный сценарий работы с формами:

  • форма как feature в архитектуре (features/todoCreate)
  • React Hook Form для состояния формы без ручной рутины
  • схема Zod как единый контракт типов и валидации
  • обработка “полевых” ошибок и “общей” ошибки сервера
  • UX улучшения: момент показа ошибок, блокировка сабмита, фокус на первое невалидное поле
  • Дальше это станет базой для следующих тем курса:

  • в TanStack Query вы будете отправлять формы как мутации и автоматически обновлять кеш
  • в Zustand/Redux вы сможете класть часть клиентского состояния формы или результата
  • вы сможете строить более сложные формы: массивы полей, зависимые поля, многошаговые сценарии
  • 6. Состояние приложения: Redux Toolkit или Zustand, селекторы, оптимизация

    Состояние приложения: Redux Toolkit или Zustand, селекторы, оптимизация

    Зачем вообще нужен глобальный state, если уже есть React state и TanStack Query

    В прошлых статьях вы уже научились:

  • грузить данные и держать состояние страницы локально (LoadState<T>)
  • строить приложение из страниц и фич через роутинг
  • делать формы через React Hook Form + Zod
  • Теперь добавим ещё один слой: клиентское состояние приложения, которое нужно сразу нескольким частям UI.

    Важно разделять два вида состояния:

  • Server state: данные с сервера (списки, детали, профили) и их жизненный цикл (кеш, инвалидация, ретраи)
  • Client state: то, что живёт только на клиенте (фильтры, выбранные табы, модалки, тема, черновики)
  • TanStack Query (к нему мы придём дальше) решает первую категорию лучше любого Redux. Redux/Zustand полезны для второй.

    !Диаграмма разделения server state и client state

    Redux Toolkit и Zustand: что выбрать

    Оба решения подходят для продакшена. Выбор зависит от масштаба, требований к структуре и привычек команды.

    | Критерий | Redux Toolkit | Zustand | |---|---|---| | Порог входа | Выше (store, slice, Provider, типизация) | Ниже (один store-файл и хуки) | | Структура и предсказуемость | Сильная (паттерны, DevTools, явные action) | Гибкая (можно организовать как угодно) | | Большие команды и регламенты | Обычно удобнее | Тоже возможно, но дисциплина важнее | | Оптимизация ререндеров | useSelector + селекторы + мемоизация | Селекторы в useStore + shallow | | Инструментарий | Очень богатый экосистемой | Минималистичный, но достаточный |

    Практическое правило для курса:

  • если хотите минимум церемоний и быстро получить global state для UI: берите Zustand
  • если хотите стандартизированную архитектуру и “как в большинстве больших проектов”: берите Redux Toolkit
  • Дальше в статье будут показаны оба варианта на одном и том же сценарии: вынесем filter и theme в глобальный state и подключим их к TodosPage и AppLayout.

    Что именно мы вынесем в global state

    Мы уже делали фильтр списка todos через useState внутри TodosPage. Это нормально, пока фильтр нужен только этой странице.

    Но в реальном приложении часто возникает:

  • фильтр должен сохраняться при уходе на детальную страницу и возврате
  • фильтр должен быть доступен из разных мест (например, “очистить фильтр” в шапке)
  • тема приложения должна работать на всех страницах
  • Вот это и есть хорошая цель для Redux/Zustand.

    Вариант A: Zustand

    Официальная документация: Zustand

    Установка

    Создаём UI-store

    По нашей архитектуре это общая инфраструктура, поэтому положим в shared.

    Создайте файл src/shared/store/uiStore.ts:

    Здесь две ключевые идеи:

  • useUiStore(selector) подписывает компонент только на выбранный кусок состояния
  • shallow помогает, когда селектор возвращает объект (иначе будет лишний ререндер из-за новой ссылки)
  • Подключаем store к AppLayout

    Обновите src/app/layout/AppLayout.tsx:

    Подключаем filter к TodosPage

    Обновите src/pages/TodosPage/TodosPage.tsx: заменим локальный filter на глобальный.

    Оптимизация в Zustand: базовые правила

  • Подписывайтесь через селектор на минимально нужные поля
  • Не возвращайте из селектора новые объекты/массивы без shallow
  • Производные значения (например, filter.trim().toLowerCase()) держите в useMemo или делайте отдельным селектором, если их используют много компонентов
  • Вариант B: Redux Toolkit

    Официальная документация: Redux Toolkit

    Установка

    Настраиваем store

    Создайте src/app/store/uiSlice.ts:

    Создайте src/app/store/store.ts:

    Типизированные хуки

    Создайте src/app/store/hooks.ts:

    Подключаем Provider на уровне app

    Создайте src/app/providers/StoreProvider.tsx:

    Обновите src/main.tsx:

    Селекторы

    Создайте src/app/store/uiSelectors.ts:

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

    Redux Toolkit реэкспортирует createSelector (внутри используется Reselect): Reselect

    Пример: нормализуем фильтр один раз.

    Подключаем к AppLayout

    Подключаем к TodosPage

    Селекторы и оптимизация: общий набор правил

    Официальная документация по хукам React-Redux: React Redux API: Hooks

    Правило: подписывайтесь на минимум

    Плохо:

  • useSelector((state) => state)
  • Хорошо:

  • useSelector(selectTodosFilter)
  • useUiStore((s) => s.todosFilter)
  • Чем меньше кусок состояния, тем меньше лишних ререндеров.

    Правило: не создавайте новые объекты в селекторе без причины

    Плохой паттерн:

    Даже если todosFilter не менялся, объект { filter: ... } создаётся заново, и компонент будет чаще ререндериться.

    Как исправлять:

  • выбирайте примитивы по отдельности
  • используйте мемоизированные селекторы (createSelector) если возвращаете объект
  • в Zustand используйте shallow, если возвращаете объект из селектора
  • Правило: мемоизируйте производное состояние там, где оно живёт

    Производное состояние это то, что вычисляется из другого состояния.

    Пример:

  • filteredTodos вычисляется из todos (данные) и filter (client state)
  • Подходы:

  • локально в компоненте через useMemo, если используется в одном месте
  • селектором через createSelector, если используется в нескольких местах и важно кешировать результат
  • Правило: разделяйте client state и server state

    Если вы начнёте складывать в Redux/Zustand массивы todos из API, то вам придётся самостоятельно решать:

  • кеширование
  • refetch
  • дедупликацию запросов
  • синхронизацию нескольких компонентов
  • Именно поэтому в современном стеке обычно так:

  • TanStack Query хранит server state
  • Redux/Zustand хранят client state
  • В следующем блоке курса TanStack Query возьмёт на себя загрузку todos и деталей, а store останется для UI-настроек и сценариев.

    Итог

    Вы добавили в приложение управляемое глобальное состояние и научились держать его производительным:

  • разобрали разницу между server state и client state
  • внедрили глобальный UI-state на выбор: Zustand или Redux Toolkit
  • подключили theme и todosFilter к AppLayout и TodosPage
  • поняли, зачем нужны селекторы и почему они влияют на ререндеры
  • Дальше это станет опорой для современного data-слоя:

  • TanStack Query будет управлять данными API
  • store останется для UI и “склейки” сценариев между страницами
  • 7. Серверные данные: TanStack Query, кеш, мутации, финальная сборка проекта

    Серверные данные: TanStack Query, кеш, мутации, финальная сборка проекта

    Зачем TanStack Query, если мы уже умеем fetch и useEffect

    В прошлых статьях вы вручную реализовали загрузку данных:

  • состояния loading/error/success
  • отмену запросов через AbortController
  • разнос API по модулям shared/api и entities/*/api
  • Это отличная база, но в реальном приложении быстро появляются типовые проблемы:

  • один и тот же запрос нужен в нескольких местах
  • нужен кеш, чтобы не дёргать сеть каждый раз при переходах
  • нужно уметь инвалидировать данные после мутаций (создали todo → список должен обновиться)
  • нужны повторные запросы, дедупликация, фоновые обновления
  • TanStack Query решает эти задачи как специализированный слой для server state.

  • Документация: TanStack Query (React)
  • > Практическое правило: server state хранится в TanStack Query, client state (тема, фильтры, модалки) остаётся в Redux/Zustand или локальном состоянии.

    !Как TanStack Query становится прослойкой между UI и API и управляет кешем и обновлениями

    Что мы сделаем в этой статье

  • Подключим TanStack Query на уровне app
  • Переведём список и детали todo на useQuery
  • Переведём создание todo на useMutation
  • Добавим инвалидацию кеша после создания
  • Добавим prefetch деталей (ускорение перехода)
  • Подготовим проект к финальной сборке: env-переменные, build/preview, чек-лист продакшен-готовности
  • Установка и подключение QueryClient

    Установка пакетов

  • @tanstack/react-query — основные хуки и кеш
  • @tanstack/react-query-devtools — удобная отладка запросов в dev
  • Документация Devtools: React Query Devtools

    Провайдер на уровне приложения

    Создайте src/app/providers/QueryProvider.tsx:

    Что означают ключевые настройки:

  • staleTime — сколько миллисекунд данные считаются свежими и не требуют рефетча при повторном маунте
  • gcTime — сколько миллисекунд неиспользуемые данные держатся в кеше, пока их не соберёт GC
  • retry — сколько раз повторять запрос при ошибке
  • Подключение провайдера в main.tsx

    Обновите src/main.tsx, добавив QueryProvider.

    Если у вас Redux Provider, композиция будет примерно такая:

    Если вы используете Zustand, StoreProvider не нужен.

    Запросы: ключи, useQuery, передача AbortSignal

    Почему важны query keys

    TanStack Query кеширует данные по query key.

    Практические правила:

  • ключ должен быть стабильным (одинаковым для одинаковых данных)
  • ключ должен быть уникальным (разные данные → разные ключи)
  • ключ обычно хранится рядом с сущностью (entities) и переиспользуется
  • Создайте src/entities/todo/model/todoKeys.ts:

    Здесь:

  • todoKeys.list({ scope: "all" }) — ключ для списка
  • todoKeys.detail(id) — ключ для конкретного todo
  • Передача signal в API

    У вас API уже принимает signal?: AbortSignal. TanStack Query умеет отменять запросы и передаёт AbortSignal в queryFn.

    Официально: Query Functions

    Хуки сущности: useTodosQuery и useTodoQuery

    Чтобы страницы не знали детали query key и запроса, удобно сделать хуки рядом с сущностью.

    Создайте src/entities/todo/queries/todoQueries.ts:

    Почему enabled важно:

  • useParams() даёт строку, Number(params.id) может стать NaN
  • при enabled: false запрос не стартует
  • Перевод TodosPage на TanStack Query

    Теперь список todo загружается автоматически при заходе на страницу.

    Обновите src/pages/TodosPage/TodosPage.tsx (пример):

    Что изменилось по сравнению с ручной реализацией:

    | Задача | Было (ручной подход) | Стало (TanStack Query) | |---|---|---| | Хранение данных | useState | data из useQuery | | loading/error | LoadState<T> | isPending/isError/error | | Отмена запросов | AbortController вручную | signal управляется Query | | Повторный запрос | своя логика | refetch() | | Кеш при возвращении на страницу | нужно писать | работает автоматически |

    Prefetch деталей todo для ускорения переходов

    Идея: когда пользователь наводит мышь на ссылку todo, мы заранее загружаем детали в кеш.

    Обновите src/entities/todo/ui/TodoItem.tsx:

    Что важно:

  • mutation.isPending заменяет ручной loading
  • успешное создание вызывает инвалидацию списка и обновляет кеш
  • навигация на деталку использует уже подготовленный кеш
  • Практическая настройка API base URL через env

    Чтобы подготовиться к реальному бэкенду, вынесите базовый URL в переменную окружения.

    Создайте .env в корне проекта:

    Обновите src/entities/todo/api/todoApi.ts:

    Добавьте .env в .gitignore, а в репозиторий положите .env.example.

    Финальная сборка проекта

    Команды, которые должен уметь прогонять каждый проект

  • pnpm lint — проверка правил ESLint
  • pnpm build — production-сборка
  • pnpm preview — локальный запуск production-сборки
  • Чек-лист перед тем как считать проект “готовым”

  • Нет ошибок TypeScript при сборке
  • ESLint не падает
  • Все страницы доступны через роутинг (/, /todos, /todos/new, /todos/:id)
  • Без сети приложение показывает понятную ошибку на страницах с данными
  • Devtools TanStack Query подключены только в dev (у вас это уже так, потому что сборщик удалит dev-зависимость из production-бандла)
  • Итог

    Вы собрали современный data-слой для фронтенд-приложения:

  • подключили TanStack Query на уровне app
  • вынесли query keys и хуки рядом с сущностью todo
  • перевели список и детали на useQuery с кешем и отменой через signal
  • перевели создание на useMutation и сделали инвалидацию/обновление кеша
  • добавили prefetch деталей для ускорения UX
  • подготовили проект к production-сборке и вынесли базовый URL API в env