From 4a861d4702d3f6be818b3eae196bdd6d9c59ad57 Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Sun, 28 Jun 2026 14:04:52 +0200 Subject: [PATCH 01/18] docs(wiki): add state management overview page Restore the state management theory page (Flux, glossary, library landscape, decision tree) authored before the FSD detour. Update stale App references to HomePage and align the chapter plan with FSD slice placement (todoSlice in entities/todo/model, filterSlice in features/filter-todos/model, store in app/). Add README entry. --- wiki/README.md | 1 + wiki/state-management.md | 280 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 wiki/state-management.md diff --git a/wiki/README.md b/wiki/README.md index 2a0aa57..ffe272d 100644 --- a/wiki/README.md +++ b/wiki/README.md @@ -18,3 +18,4 @@ For product documentation (user stories, feature specs), see [docs/](../docs/REA - [Immutable state in React](./immutable-state.md) - [Derived state in React](./derived-state.md) - [Feature-Sliced Design](./fsd-architecture.md) +- [State management (overview & decision guide)](./state-management.md) diff --git a/wiki/state-management.md b/wiki/state-management.md new file mode 100644 index 0000000..5fb3bc9 --- /dev/null +++ b/wiki/state-management.md @@ -0,0 +1,280 @@ +# State management + +> Большинству приложений библиотека для управления состоянием не нужна. Тем, кому нужна, без неё больно. Эта страница про то, как понять к какому типу относится твой проект и какие компромиссы есть у разных решений. + +## Что вообще такое «state» + +Под этим словом в React-приложении прячется несколько разных вещей. У них разные сроки жизни и разные «правильные» решения. + +| Категория | Пример | Что подходит | +| -------------------- | ----------------------------------------------------------- | ----------------------------------------------------- | +| **UI state** | «Открыт ли этот dropdown?» | `useState` | +| **Form state** | Инпуты которые пользователь печатает | `useState` (или `react-hook-form` если формы большие) | +| **URL state** | «Какая сейчас страница? Какие фильтры в query-string?» | Router (React Router, TanStack Router) | +| **Server state** | Данные с API, кэш, возможно устаревшие | `react-query`, `SWR`, RTK Query | +| **Global app state** | Сквозные доменные данные (current user, todos, theme, cart) | Вот для этого state-менеджеры | + +Эта страница про **последнюю строку**. Частая ошибка — хватать Redux чтобы решить URL state или server state. Не тот инструмент → больше боли чем просто взять router или библиотеку для запросов. + +## Когда `useState` достаточно + +Большинство компонентов в большинстве случаев. Конкретно: + +- State принадлежит одному компоненту (open/close модалки, hover, focus) +- State потребляется парой детей (поднимаем до ближайшего общего родителя) +- Дерево компонентов неглубокое — пропсы путешествуют на 1-2 уровня + +Наша текущая TODO-app в этой зоне. `HomePage` хранит `todos` и `filter`, дерево 3 уровня, prop drilling помещается на один экран. Тянуть библиотеку сейчас — преждевременно. + +> Правило: остаёмся на `useState` пока он реально не начнёт мешать. Не решаем боль которой у нас ещё нет. + +## Когда `useState` ломается + +Четыре конкретные ситуации где локальный state перестаёт масштабироваться. Каждая показана на гипотетическом расширении нашей App — чтобы боль почувствовалась на знакомом коде. + +### 1. Prop drilling + +Пропсы передаются через компоненты которые их не используют — только чтобы дойти до нужного потомка. + +Представь что мы добавляем `currentUser` нужный внутри `TodoItem` для надписи «added by Alex». Путь: + +``` +App (знает user) + └── TodoList (ему не нужно, но обязан принять и пробросить) + └── TodoItem (реально использует) +``` + +У `TodoList` теперь есть пропс `currentUser` который он не читает — он существует только как курьер. Добавь ещё три таких пропса (theme, locale, permissions) — список пропсов `TodoList` превращается в шум. Компонент перестаёт быть переиспользуемым в изоляции. + +### 2. Синхронизация между далёкими ветками + +Две части UI должны реагировать на один state, но живут в разных поддеревьях. + +Добавим в шапку `` показывающий «5 active tasks». Ему нужен `todos` чтобы считать. С `useState`, `todos` должен жить в **ближайшем общем родителе** `TaskCounter` и `TodoList` — то есть в `HomePage` — и передаваться вниз обоими путями. С ростом приложения «ближайший общий родитель» уползает наверх пока не становится корнем. + +### 3. Связанные multi-step обновления + +Одно действие пользователя должно поменять несколько state-кусков **атомарно**. + +Представь «Bulk delete completed и сбросить фильтр в All»: + +```ts +const handleClearCompleted = () => { + setTodos((prev) => prev.filter((t) => !t.completed)); + setFilter('all'); + setLastAction({ type: 'clear-completed', at: Date.now() }); +}; +``` + +Три вызова `setState`, три замыкания, три ререндера. Если забудешь один — state рассыпается. Логика — «вот что значит clear completed» — размазана, не имеет имени, не тестируется в изоляции. + +Это решает **reducer** — чистая функция-обработчик (подробное определение в следующей секции про Flux). Одно действие `clearCompleted`, одно место которое знает что оно делает, один рендер. + +### 4. State лежит далеко от того где используется + +Поднимаешь state наверх пока оба потребителя не смогут его прочитать. Иногда это «наверх» — корень, хотя реальные потребители — глубокие изолированные листья. Корневой компонент превращается в контейнер для state и handler'ов которыми он сам не пользуется. + +Наш `HomePage` (`pages/home`) подходит к этому краю — он владеет 5 кусками state и 3 handler'ами, ни один из которых он визуально не рендерит. Чистый оркестратор. Пока ок. С 20 кусками state и 15 handler'ами это превращается в кошмар поддержки. + +## Архитектура Flux + +Каждая современная библиотека управления состоянием (Redux, Zustand, MobX-адаптеры, etc.) — вариация паттерна **Flux**, изначально предложенного Facebook в 2014. Понимание Flux концептуально важнее чем знание любой конкретной библиотеки. + +### Словарь терминов + +Перед правилами и диаграммой — короткий глоссарий. Дальше эти слова используются часто, без него можно запутаться. + +- **Store** — единственный объект, хранящий **всё** состояние приложения. Один на всё приложение. Имеет API: читать state и принимать actions. +- **State** — текущее состояние приложения. Обычно один большой иммутабельный объект, например `{ todos: [...], filter: 'all', user: {...} }`. +- **Action** — обычный JS-объект описывающий **что произошло** (не «что сделать»). Минимум — поле `type` (строка). Часто плюс `payload` с данными. Пример: `{ type: 'todo/added', payload: { text: 'Buy milk' } }`. Это просто описание события, не функция и не команда. +- **Payload** — данные внутри action'а. По соглашению лежат в поле с именем `payload`. +- **Dispatch** — функция отправляющая action в store: `store.dispatch({ type: 'todo/added', payload: ... })`. **Единственный** легальный способ изменить state. +- **Reducer** — чистая функция с сигнатурой `(state, action) => newState`. Принимает текущий state и action — возвращает новый state. Ты её не вызываешь напрямую — store сам вызывает на каждый dispatch. +- **Selector** — функция читающая часть state: `(state) => state.todos.items`. Компоненты используют их (через хук `useSelector`) чтобы подписаться на конкретные кусочки, а не на весь store. +- **Middleware** — функция-перехватчик между dispatch и reducer'ом. Может логировать, делать async-запросы, отменять actions, что угодно. Концептуально как middleware в Express/Koa. + +Теперь три правила Flux: + +1. **Single source of truth** — есть **один** store который содержит всё состояние приложения. +2. **State read-only** — компоненты никогда не мутируют state напрямую. Они эмитят **actions** которые описывают что произошло. +3. **Изменения делаются чистыми функциями** — reducer'ы берут предыдущий state и action, возвращают новый state. Никаких side effects, никаких мутаций. + +### Однонаправленный поток данных + +``` + ┌─────────────┐ + │ Store │ ← единственный источник истины + └──────┬──────┘ + │ (state) + ▼ + ┌─────────────┐ + │ Components │ ← читают state, рендерят UI + └──────┬──────┘ + │ (событие от пользователя) + ▼ + ┌─────────────┐ + │ Action │ ← описывает ЧТО случилось ({ type: 'todo/added', payload }) + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ Reducer │ ← (state, action) => newState + └──────┬──────┘ + │ + └──→ обновляет Store ──→ Components ререндерятся +``` + +Обрати внимание: данные текут в **одну сторону**. Компоненты не пишут в store напрямую. Они диспатчат actions. Reducer — единственное место где state меняется. Это то что делает систему **предсказуемой** — при одном и том же начальном state и одной и той же последовательности actions результат всегда одинаковый. + +### Почему это важно + +- **Предсказуемость** — можно логировать каждое action и переиграть их чтобы восстановить любое прошлое состояние (на этом работает time-travel debugging в Redux DevTools) +- **Тестируемость** — reducer'ы чистые функции; тестируешь их без React, без DOM +- **Reasoning** — на вопрос «как state стал таким?» всегда есть ответ: «эта последовательность actions прошла через эти reducer'ы» + +Цена — **церемония**: больше файлов, больше именованных actions, больше indirection между пользовательским вводом и изменением state. Стоит ли эта цена — зависит от сложности приложения. + +## Зачем тут immutability + +Мы это разбирали в [immutable-state.md](./immutable-state.md), но в контексте Flux стоит повторить. + +Reducer'ы должны возвращать **новый объект state**, не мутировать старый. Почему: + +1. **Обнаружение изменений** — store решает уведомлять подписчиков или нет через сравнение `prevState === newState`. Если ты мутируешь — ссылка та же → ререндера не будет. +2. **Time travel** — devtools хранят каждое прошлое состояние. Если состояния мутируются — «перемотка назад» показывает одинаковое (финальное) состояние на каждом шаге. +3. **Конкурентность** — React concurrent rendering может рендерить несколько состояний параллельно. Если они шарят мутабельные ссылки — рендеры мешают друг другу. + +Redux Toolkit «жульничает» через **Immer**: ты пишешь код будто мутируешь, Immer под капотом производит настоящую иммутабельную копию. До этого мы дойдём когда возьмёмся за RTK. Не путайся: модель всё равно immutability, синтаксис просто прячет boilerplate. + +## Когда `useContext + useReducer` достаточно + +Flux встроен в React. Библиотека не всегда нужна. + +```tsx +const TodoContext = createContext(null); + +function todoReducer(state: TodoState, action: TodoAction): TodoState { + switch (action.type) { + case 'added': + return { ...state, items: [...state.items, action.payload] }; + // ... + } +} + +function TodoProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(todoReducer, initialState); + return {children}; +} +``` + +Получаешь: single store, actions, reducers, immutability. Без библиотеки. + +### Когда хватает + +- Один домен state (только todos, или только user — не 10 несвязанных слайсов) +- Маленькое или среднее приложение +- Не нужен time-travel debugging +- Не нужен middleware (логирование, persistence, async-оркестрация) +- Команда комфортно работает с React-примитивами + +### Когда перестаёт работать + +- **Производительность** — каждый компонент потребляющий Context **ререндерится на любое изменение этого Context'а**, даже если ему нужно одно поле. Redux-селекторы это решают; Context — нет. +- **Нет devtools** — не можешь инспектить actions, переигрывать их, time-travel. +- **Нет стандартного паттерна для async** — `useReducer` синхронный. Для async (fetch, debounce) каждый раз пишешь сам. +- **Композиция неудобная** — несколько Context'ов вкладываются друг в друга, провайдеры стэкаются; библиотеки решают это одним store. + +Правило большого пальца в React-сообществе: **сначала пробуй `useContext + useReducer`.** Тянись за библиотекой только когда упёрся в один из лимитов выше. + +## Ландшафт библиотек + +Популярные опции на 2026: + +| Библиотека | Парадигма | Boilerplate | DevTools | Async | Заметки | +| --------------- | ---------- | ----------- | -------- | --------------------------- | ----------------------------------------------------------------------------------- | +| **Redux + RTK** | Flux | Средний | Отличные | Встроен (thunks, RTK Query) | Индустриальный стандарт. Большинство джоб, самый эксплицитный, самый предсказуемый. | +| **Zustand** | Flux-lite | Минимальный | Хорошие | Вручную / async-в-store | «Идеи Redux, 10% кода». API через один хук. | +| **MobX** | Observable | Низкий | Хорошие | Нативно (reactions) | Основан на мутациях. Реактивные proxy. Другая ментальная модель. | +| **Jotai** | Atoms | Низкий | Хорошие | Suspense-friendly | Bottom-up: много маленьких atoms вместо одного store. | +| **Recoil** | Atoms | Низкий | Хорошие | Suspense-friendly | Заброшен Meta в 2024. Не использовать в новых проектах. | + +### Профиль в одном абзаце + +**Redux + RTK** — эксплицитная каноническая реализация Flux. Многословный by design: каждое изменение — именованный action, каждое изменение залогировано, каждое можно переиграть. RTK (Redux Toolkit) убрал большую часть исторического boilerplate «vanilla Redux» — slices, Immer, configureStore, RTK Query для async. Всё равно больше церемонии чем у альтернатив, но платишь за предсказуемость и инструменты. Большинство production React-приложений на работе используют именно Redux. + +**Zustand** — сделан создателем react-three-fiber. Берёт идею Flux но выкидывает церемонию: нет провайдеров, нет actions-как-объектов, нет reducer'ов — просто хук возвращающий state и сеттеры. Ментальная модель: «useState, доступный отовсюду». Отлично для маленьких и средних приложений. Нет того async-инструментария который есть у RTK Query. + +**MobX** — совсем другая парадигма. Объявляешь observable-объекты, мутируешь их естественно, компоненты которые их читают автоматически ререндерятся. Никаких actions, никаких reducer'ов. Меняет эксплицитный «лог что произошло» из Flux на эргономику. Часто встречается в enterprise / Angular-style командах. + +**Jotai** — вместо одного большого store у тебя много маленьких «atoms» (примитивных единиц состояния). Компоненты подписываются на конкретные atoms. Гранулярно, suspense-friendly, хорошо ложится на concurrent features React'а. Лучший выбор когда state естественно фрагментирован. + +## Для чего state-менеджеры НЕ нужны + +Самая частая ошибка. Люди хватают Redux чтобы решить задачи которые вообще не про управление состоянием. + +| Если нужно... | Бери это | Почему не Redux | +| -------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------- | +| Серверные данные (fetch, кэш, refetch) | `react-query` / `SWR` / RTK Query | У серверных данных свой lifecycle (stale, refetching, error) который state-менеджеры не моделируют | +| Текущий URL / query params | React Router / TanStack Router | URL уже глобальное состояние, им владеет браузер, синхронизировать вручную = баги | +| Формы с валидацией | `react-hook-form` / `formik` | У форм свои заботы: валидация, submission, dirty tracking | +| Theme, locale, один boolean | `useContext` | Overkill — Context для этого и придуман | +| Анимации | Framer Motion / state machines | State-менеджеры не моделируют переходы во времени | + +> Полезный тест: «Этот state **мутируется** действиями пользователя в разных местах приложения?» Если да — state manager. Если нет (он вычисляется, фетчится, или принадлежит URL/форме/анимации) — другой инструмент. + +## Дерево решений + +``` +Откуда берётся state? +│ +├─ Приходит с сервера / по сети? → react-query, SWR, RTK Query +├─ Живёт в URL? → router (React Router) +├─ Принадлежит одной форме? → react-hook-form (если сложная) или useState +├─ Принадлежит одному компоненту? → useState +├─ Шарится в небольшом поддереве? → lift up + useState +├─ Сквозной, один домен, +│ маленькое/среднее приложение? → useContext + useReducer +├─ Сквозной, несколько доменов, +│ нужны devtools / middleware? → Redux + RTK (или Zustand если можно) +└─ Предпочитаешь реактивную / + observable модель? → MobX +``` + +Универсально «лучшего» выбора нет — только «лучший для этого приложения, этой команды, этой стадии». + +## Почему мы будем учить Redux первым + +Для учебного проекта быстрее было бы взять Zustand. Тогда почему Redux? + +1. **Самая эксплицитная реализация Flux.** Каждое понятие — store, action, reducer, dispatch, selector, middleware — отдельная именованная вещь. После того как ты увидел всё это эксплицитно, любая другая библиотека читается за 30 минут, потому что узнаёшь паттерн в более компактной форме. + +2. **Обратный путь не работает.** Изучи сначала Zustand — получишь рабочий инструмент, но не ментальную модель **почему** state-менеджеры так устроены. Когда встретишь Redux на работе (а ты встретишь) — он будет казаться чужим. + +3. **Реальность индустрии.** Большинство React-джоб используют Redux. Понимание его — базовое ожидание в экосистеме. + +4. **Зрелость экосистемы.** RTK Query, Redux DevTools, экосистема middleware — инструментарий вокруг Redux ни с чем не сравнить. Поняв ядро, ты получаешь доступ ко всему этому. + +После Redux мы сделаем главу сравнения: тот же Todo-store переписать на Zustand и MobX. Контраст «до/после» — момент когда уроки закрепляются. + +## Что мы будем делать в этом проекте + +Конкретный план следующей главы (ложится на нашу FSD-структуру): + +1. Поднять RTK в проекте: `store` и `Provider` в слое `app/`, типизированные хуки `useAppSelector` / `useAppDispatch` +2. Мигрировать `todos` из локального state в slice — `entities/todo/model/todoSlice.ts` +3. Мигрировать `filter` в свой slice — `features/filter-todos/model/filterSlice.ts` +4. Использовать селекторы для derived state (`visibleTodos`), рядом со слайсами в `model/` +5. Убрать prop drilling — компоненты диспатчат напрямую +6. Честный разбор trade-offs: что улучшилось, что усложнилось +7. Сравнение с Zustand и MobX (тот же домен, разные стеки) + +Ничто из этого не нужно чтобы зашипить рабочее TODO. Текущая версия на `useState` нормальная. Цель — **понять trade space** чтобы в будущих проектах узнавать когда тяга к state-менеджеру оправдана и какой именно подходит. + +## См. также + +- [Lifting state up](./lifting-state-up.md) — предусловие которое заставляет думать об ownership'е state +- [Feature-Sliced Design](./fsd-architecture.md) — где физически живут store, слайсы и селекторы в нашем проекте +- [Immutable state in React](./immutable-state.md) — фундамент на котором работает Flux +- [Derived state in React](./derived-state.md) — что **не надо** класть ни в какой store, локальный или глобальный +- [React docs: Managing State](https://react.dev/learn/managing-state) +- [Redux: Style Guide](https://redux.js.org/style-guide/) — официальные соглашения, неожиданно мнений-ориентированные From ef2d944598ca96e48a0f38db2af12c3c16b968b9 Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Sun, 28 Jun 2026 14:26:00 +0200 Subject: [PATCH 02/18] feat(redux): set up RTK store, typed hooks and todo slice Install @reduxjs/toolkit and react-redux. Add app/store.ts (configureStore + RootState/AppDispatch), app/hooks.ts (typed useAppSelector/useAppDispatch), wrap the app in , and add entities/todo/model/todoSlice.ts with todoAdded/todoToggled/todoDeleted. Add a documented boundaries exception so any layer may import @/app/store and @/app/hooks (the store is cross-cutting infra). Components are not wired to the store yet. --- eslint.config.js | 10 +++ package-lock.json | 100 +++++++++++++++++++++++++-- package.json | 2 + src/app/hooks.ts | 8 +++ src/app/main.tsx | 6 +- src/app/store.ts | 12 ++++ src/entities/todo/index.ts | 1 + src/entities/todo/model/todoSlice.ts | 42 +++++++++++ 8 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 src/app/hooks.ts create mode 100644 src/app/store.ts create mode 100644 src/entities/todo/model/todoSlice.ts diff --git a/eslint.config.js b/eslint.config.js index 470a15c..e32c004 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -87,6 +87,16 @@ export default defineConfig([ message: 'Import a slice through its public API (index), not its internal files: {{ dependency.source }}', }, + // 1c. Documented exception: the Redux store and its typed hooks live + // in `app/` because the store composes every slice and must sit at + // the top layer. They are cross-cutting infrastructure, so any layer + // may import `@/app/store` and `@/app/hooks`. This is the single + // sanctioned upward import. (A stricter alternative — hooks in + // shared with reducer injection — is noted in wiki/redux-setup.md.) + { + from: { type: '*' }, + allow: [{ to: { type: 'app', internalPath: '{store,hooks}.{ts,tsx}' } }], + }, ], }, ], diff --git a/package-lock.json b/package-lock.json index 1a95d58..efb0e6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "edu-react-app", "version": "0.0.0", "dependencies": { + "@reduxjs/toolkit": "^2.12.0", "@tailwindcss/vite": "^4.3.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-redux": "^9.3.0", "tailwindcss": "^4.3.0" }, "devDependencies": { @@ -1822,6 +1824,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", @@ -2115,7 +2143,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@storybook/addon-a11y": { @@ -2932,7 +2965,7 @@ "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2955,6 +2988,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.4", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", @@ -4239,7 +4278,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -5131,6 +5170,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -6459,6 +6508,29 @@ "license": "MIT", "peer": true }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -6503,6 +6575,27 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -7192,7 +7285,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "dev": true, "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index e92cfcd..219528c 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,11 @@ "build-storybook": "storybook build" }, "dependencies": { + "@reduxjs/toolkit": "^2.12.0", "@tailwindcss/vite": "^4.3.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-redux": "^9.3.0", "tailwindcss": "^4.3.0" }, "devDependencies": { diff --git a/src/app/hooks.ts b/src/app/hooks.ts new file mode 100644 index 0000000..a3b7db1 --- /dev/null +++ b/src/app/hooks.ts @@ -0,0 +1,8 @@ +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from './store'; + +// Typed wrappers around the React-Redux hooks. Use these throughout the app +// instead of the plain useDispatch / useSelector so dispatch and selectors are +// fully typed against our store. +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/src/app/main.tsx b/src/app/main.tsx index 80399d0..b135856 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -1,10 +1,14 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { Provider } from 'react-redux'; import './index.css'; +import { store } from './store'; import { HomePage } from '@/pages/home'; createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/src/app/store.ts b/src/app/store.ts new file mode 100644 index 0000000..62e91f6 --- /dev/null +++ b/src/app/store.ts @@ -0,0 +1,12 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { todoReducer } from '@/entities/todo'; + +export const store = configureStore({ + reducer: { + todos: todoReducer, + }, +}); + +// Inferred from the store itself so the types always match the real reducers. +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/src/entities/todo/index.ts b/src/entities/todo/index.ts index c3230c5..a1e2814 100644 --- a/src/entities/todo/index.ts +++ b/src/entities/todo/index.ts @@ -1,2 +1,3 @@ export { TodoItem } from './ui/TodoItem'; export type { Todo } from './model/types'; +export { todoReducer, todoAdded, todoToggled, todoDeleted } from './model/todoSlice'; diff --git a/src/entities/todo/model/todoSlice.ts b/src/entities/todo/model/todoSlice.ts new file mode 100644 index 0000000..2d119dc --- /dev/null +++ b/src/entities/todo/model/todoSlice.ts @@ -0,0 +1,42 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import type { Todo } from './types'; + +interface TodosState { + items: Todo[]; +} + +const initialState: TodosState = { + items: [], +}; + +const todoSlice = createSlice({ + name: 'todos', + initialState, + reducers: { + // `prepare` lets the caller pass just the text; the id is generated here, + // so the action stays a description of "what happened" and the component + // does not need to know how ids are made. + todoAdded: { + reducer(state, action: PayloadAction) { + state.items.push(action.payload); + }, + prepare(text: string) { + return { + payload: { id: crypto.randomUUID(), text, completed: false } satisfies Todo, + }; + }, + }, + todoToggled(state, action: PayloadAction) { + const todo = state.items.find((item) => item.id === action.payload); + if (todo) { + todo.completed = !todo.completed; + } + }, + todoDeleted(state, action: PayloadAction) { + state.items = state.items.filter((item) => item.id !== action.payload); + }, + }, +}); + +export const { todoAdded, todoToggled, todoDeleted } = todoSlice.actions; +export const todoReducer = todoSlice.reducer; From 329ae8b0c0d0658f3e0161c00a871960511f3d1b Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Sun, 28 Jun 2026 14:26:00 +0200 Subject: [PATCH 03/18] docs(wiki): add Redux setup page Explain RTK install, configureStore, typed hooks, Provider, the first slice (createSlice + Immer + prepare), FSD placement and the app-store boundary exception with the reducer-injection alternative noted. --- wiki/README.md | 1 + wiki/redux-setup.md | 161 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 wiki/redux-setup.md diff --git a/wiki/README.md b/wiki/README.md index ffe272d..14dae2d 100644 --- a/wiki/README.md +++ b/wiki/README.md @@ -19,3 +19,4 @@ For product documentation (user stories, feature specs), see [docs/](../docs/REA - [Derived state in React](./derived-state.md) - [Feature-Sliced Design](./fsd-architecture.md) - [State management (overview & decision guide)](./state-management.md) +- [Redux setup (RTK)](./redux-setup.md) diff --git a/wiki/redux-setup.md b/wiki/redux-setup.md new file mode 100644 index 0000000..d008074 --- /dev/null +++ b/wiki/redux-setup.md @@ -0,0 +1,161 @@ +# Redux setup (RTK) + +Поднятие Redux Toolkit в проекте: store, типизированные хуки, Provider и первый slice. Это инфраструктурный шаг — компоненты к store ещё не подключены (это следующий шаг главы). + +> Теория Flux, словарь терминов (store, action, dispatch, reducer, selector) и почему именно Redux — в [state-management.md](./state-management.md). Здесь — практика. + +## Что поставили + +```bash +npm install @reduxjs/toolkit react-redux +``` + +- **`@reduxjs/toolkit` (RTK)** — официальный, рекомендованный способ писать Redux. Убирает исторический boilerplate «vanilla Redux»: `createSlice` генерирует actions и reducer'ы, внутри встроен **Immer** (пишешь «мутирующий» код — получаешь иммутабельное обновление), `configureStore` настраивает DevTools и middleware из коробки. +- **`react-redux`** — связка Redux ↔ React: компонент `` и хуки `useSelector` / `useDispatch`. + +## Store + +`app/store.ts`: + +```ts +import { configureStore } from '@reduxjs/toolkit'; +import { todoReducer } from '@/entities/todo'; + +export const store = configureStore({ + reducer: { + todos: todoReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; +``` + +- **`configureStore`** — обёртка над `createStore`. Принимает объект `reducer`, где каждый ключ — это слой state. Здесь `todos` → `state.todos`. Под капотом делает `combineReducers`, подключает Redux DevTools и стандартный middleware (`redux-thunk`, проверки на мутации и сериализуемость в dev). +- **`RootState`** — тип **всего** состояния, выведенный **из самого store** через `ReturnType`. Не пишем его руками — добавишь slice в `reducer`, тип обновится сам. +- **`AppDispatch`** — тип функции `dispatch`, тоже выведенный из store. Он «знает» про подключённый middleware (например, умеет ли диспатчить thunk'и). + +> Почему типы выводятся из store, а не объявляются вручную: единый источник истины. Конфигурация store определяет форму state — типы просто следуют за ней. Руками = два места которые рассинхронизируются. + +## Типизированные хуки + +`app/hooks.ts`: + +```ts +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from './store'; + +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); +``` + +Зачем оборачивать стандартные хуки: + +- **`useAppSelector`** — это `useSelector`, который уже знает тип `RootState`. В `(state) => state.todos.items` параметр `state` автоматически типизирован, без ручной аннотации в каждом компоненте. +- **`useAppDispatch`** — `useDispatch` знающий `AppDispatch`. Важно для TypeScript + thunk'ов: обычный `useDispatch` не знает про middleware и будет ругаться на async-экшены. + +`.withTypes<...>()` — идиома react-redux 9 для создания пред-типизированных версий. Раньше писали `useSelector: TypedUseSelectorHook` вручную — `.withTypes` это заменило. + +**Правило:** в компонентах импортируем `useAppSelector` / `useAppDispatch`, а не голые `useSelector` / `useDispatch`. + +## Provider + +`app/main.tsx` — оборачиваем дерево в ``: + +```tsx +import { Provider } from 'react-redux'; +import { store } from './store'; +import { HomePage } from '@/pages/home'; + +createRoot(document.getElementById('root')!).render( + + + + + , +); +``` + +`` кладёт `store` в React Context, и любой компонент ниже получает к нему доступ через хуки — без prop drilling. Это и есть тот механизм, ради которого мы берём библиотеку: store доступен отовсюду, а не «пробрасывается сверху». + +## Первый slice + +`entities/todo/model/todoSlice.ts`: + +```ts +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import type { Todo } from './types'; + +interface TodosState { + items: Todo[]; +} + +const initialState: TodosState = { items: [] }; + +const todoSlice = createSlice({ + name: 'todos', + initialState, + reducers: { + todoAdded: { + reducer(state, action: PayloadAction) { + state.items.push(action.payload); + }, + prepare(text: string) { + return { payload: { id: crypto.randomUUID(), text, completed: false } satisfies Todo }; + }, + }, + todoToggled(state, action: PayloadAction) { + const todo = state.items.find((item) => item.id === action.payload); + if (todo) todo.completed = !todo.completed; + }, + todoDeleted(state, action: PayloadAction) { + state.items = state.items.filter((item) => item.id !== action.payload); + }, + }, +}); + +export const { todoAdded, todoToggled, todoDeleted } = todoSlice.actions; +export const todoReducer = todoSlice.reducer; +``` + +Что здесь происходит: + +- **`createSlice`** генерирует из одного объекта: reducer, action creators и их `type`-строки. `name: 'todos'` → actions получают type вида `'todos/todoAdded'`. +- **`state.items.push(...)` — это НЕ мутация.** Внутри `createSlice` работает **Immer**: ты пишешь привычный мутирующий код, Immer перехватывает и производит настоящую иммутабельную копию. Снаружи slice остаётся immutable (как требует Flux), внутри — читаемый код без `...spread`. +- **`PayloadAction`** — тип action'а с типизированным `payload`. +- **`prepare`** — колбэк который формирует payload до того как он попадёт в reducer. Здесь генерируем `id` внутри slice, чтобы компонент диспатчил просто `todoAdded(text)` и не знал как делаются id. Reducer и prepare — две части одного action creator. +- Экспортируем **actions** (их будут диспатчить компоненты) и **reducer** (его подключает store). + +## Где что лежит (FSD) + +| Что | Где | Почему | +| ----------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------ | +| `store`, `RootState`, `AppDispatch` | `app/store.ts` | store композирует **все** слайсы → зависит от верхних слоёв → обязан быть на самом верху (`app`) | +| `useAppSelector`, `useAppDispatch` | `app/hooks.ts` | типизированы от store, лежат рядом с ним | +| `todoReducer`, actions | `entities/todo/model/todoSlice.ts` | state сущности `Todo` принадлежит её слайсу | +| `` | `app/main.tsx` | инициализация приложения — слой `app` | + +### Напряжение FSD ↔ Redux и наш компромисс + +Тут есть конфликт, который стоит понимать: + +- **store** зависит от всех слайсов → должен быть **наверху** (`app`). +- **хуки** нужны компонентам **снизу** (`features`, `widgets`, `pages`). + +А строгое правило FSD запрещает нижним слоям импортировать `app` (импорт «вверх»). Противоречие. + +**Наш выбор:** держим store и хуки в `app/`, и разрешаем любому слою импортировать **именно** `@/app/store` и `@/app/hooks` — одно задокументированное исключение в линтере границ ([eslint.config.js](../eslint.config.js), правило `1c`). Store — это объективно cross-cutting синглтон, и так делают многие FSD+Redux проекты. + +**Более «чистая» альтернатива** (на будущее): хуки в `shared/lib/store`, типы `RootState`/`AppDispatch` пробрасываются туда через TypeScript declaration merging, а слайсы подключаются в store через reducer injection (`combineSlices().inject(...)`). Это убирает импорт «вверх» полностью, но тащит заметно больше TS-машинерии. Для первого захода в Redux это лишний шум — вернёмся к этому в главе про trade-offs. + +## Что дальше + +Store поднят, но `HomePage` всё ещё держит `todos` в `useState` — Redux-store пока **не подключён** к UI (живёт параллельно, пустой). Следующий шаг: заменить локальный `useState` на `useAppSelector` + `useAppDispatch`, удалить handler'ы из `HomePage` и начать растворять prop drilling. + +## См. также + +- [State management (обзор и дерево решений)](./state-management.md) — теория Flux и зачем Redux +- [Feature-Sliced Design](./fsd-architecture.md) — слои, слайсы, public API +- [Immutable state in React](./immutable-state.md) — почему reducer'ы возвращают новый объект (и что Immer прячет) +- [Redux Toolkit: Quick Start](https://redux-toolkit.js.org/tutorials/quick-start) +- [Redux Style Guide](https://redux.js.org/style-guide/) From 6551288b019680e12ff789195fb6fc4d6706be2a Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Sun, 28 Jun 2026 15:27:28 +0200 Subject: [PATCH 04/18] feat(redux): move todos state into the store Replace HomePage's local todos useState with useAppSelector(state => state.todos.items) and dispatch todoAdded/todoToggled/todoDeleted via useAppDispatch. The todos source of truth now lives in Redux (inspectable in DevTools). Filter stays on useState until the next step. Leaf components remain prop-driven; prop drilling removal comes later. --- src/pages/home/ui/HomePage.tsx | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/pages/home/ui/HomePage.tsx b/src/pages/home/ui/HomePage.tsx index 910a7cd..cd87cee 100644 --- a/src/pages/home/ui/HomePage.tsx +++ b/src/pages/home/ui/HomePage.tsx @@ -1,26 +1,18 @@ -import type { Todo } from '@/entities/todo'; +import { useAppDispatch, useAppSelector } from '@/app/hooks'; +import { todoAdded, todoDeleted, todoToggled } from '@/entities/todo'; import { AddTodoForm } from '@/features/add-todo'; import { TodoFilter, type FilterStatus } from '@/features/filter-todos'; import { TodoList } from '@/widgets/todo-list'; import { useState } from 'react'; function HomePage() { - const [todos, setTodos] = useState([]); + const todos = useAppSelector((state) => state.todos.items); + const dispatch = useAppDispatch(); const [filter, setFilter] = useState('all'); - const addTodo = (text: string) => { - setTodos((prev) => [...prev, { id: crypto.randomUUID(), text, completed: false }]); - }; - - const toggleTodo = (id: string) => { - setTodos((prev) => - prev.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo)), - ); - }; - - const deleteTodo = (id: string) => { - setTodos((prev) => prev.filter((todo) => todo.id !== id)); - }; + const addTodo = (text: string) => dispatch(todoAdded(text)); + const toggleTodo = (id: string) => dispatch(todoToggled(id)); + const deleteTodo = (id: string) => dispatch(todoDeleted(id)); const filteredTodos = todos.filter((todo) => { if (filter === 'active') return !todo.completed; From a2cca53c1724745c48779fd90401cac4feeb9403 Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Sun, 28 Jun 2026 16:00:20 +0200 Subject: [PATCH 05/18] docs(wiki): add static store-overview note and update next steps Add a section on how to see the whole store statically (store.ts as the domain map, RootState as the full shape, slice files as per-domain single files, index.ts as public API). Refresh the 'what's next' section now that todos is wired to the store. --- wiki/redux-setup.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/wiki/redux-setup.md b/wiki/redux-setup.md index d008074..e9f7510 100644 --- a/wiki/redux-setup.md +++ b/wiki/redux-setup.md @@ -148,9 +148,27 @@ export const todoReducer = todoSlice.reducer; **Более «чистая» альтернатива** (на будущее): хуки в `shared/lib/store`, типы `RootState`/`AppDispatch` пробрасываются туда через TypeScript declaration merging, а слайсы подключаются в store через reducer injection (`combineSlices().inject(...)`). Это убирает импорт «вверх» полностью, но тащит заметно больше TS-машинерии. Для первого захода в Redux это лишний шум — вернёмся к этому в главе про trade-offs. +## Как видеть всю картину стора (статически) + +В Zustand/MobX часто есть **один файл стора**, где видно все поля и действия сразу. Redux намеренно **распределяет** state по слайсам — цена модульности. «Один файл со всем» теряется, но увидеть всю картину без запуска сервера всё равно можно — просто через другие точки входа: + +| Хочешь увидеть… | Смотри сюда | +| ------------------------------------------ | --------------------------------------------------------------------------------- | +| Какие домены вообще есть в сторе | `app/store.ts` → объект `reducer` (это оглавление: ключ → `state.<ключ>`) | +| Полную форму всего стора | тип `RootState` (наведи курсор в IDE — покажет дерево, собранное из всех слайсов) | +| Всё состояние + все действия одного домена | его `*Slice.ts` (вот тут «один файл», но на домен, а не на всё) | +| Что домен отдаёт наружу | его `index.ts` (public API: actions, селекторы, типы) | +| Поток actions в рантайме | Redux DevTools (уже с запущенным приложением) | + +Ментальная модель: в Zustand читаешь **один файл сверху вниз**; в Redux — **`store.ts` (карта доменов) + слайсы (главы)**. Практический приём: открыл `store.ts`, увидел домены, `Go to Definition` на reducer'е прыгает прямо в слайс. Навигация по доменам вместо скролла одного гигантского файла. + +> Это честный trade-off: Redux проигрывает в «обзорности с одного взгляда», выигрывает в модульности. В главе сравнения перепишем тот же стор на Zustand и почувствуем разницу вживую. + ## Что дальше -Store поднят, но `HomePage` всё ещё держит `todos` в `useState` — Redux-store пока **не подключён** к UI (живёт параллельно, пустой). Следующий шаг: заменить локальный `useState` на `useAppSelector` + `useAppDispatch`, удалить handler'ы из `HomePage` и начать растворять prop drilling. +Store подключён к UI: `HomePage` читает `todos` через `useAppSelector` и диспатчит `todoAdded` / `todoToggled` / `todoDeleted` вместо `setState`. Источник истины для todos теперь в Redux. + +`filter` пока остаётся в локальном `useState` — намеренный контраст (todos через store, filter через props на одном экране). Следующие шаги: перенести `filter` в свой slice, выразить `visibleTodos` через селекторы и затем убрать prop drilling (компоненты обращаются к store напрямую). ## См. также From 4b208d5a0689a99e99fa2cb8c5fac224285f198b Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Mon, 29 Jun 2026 20:06:09 +0200 Subject: [PATCH 06/18] feat(redux): add filter slice Add features/filter-todos/model/filterSlice.ts with a filterChanged reducer over { status: FilterStatus }, and expose filterReducer/filterChanged through the feature public API. --- src/features/filter-todos/index.ts | 3 ++- .../filter-todos/model/filterSlice.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 src/features/filter-todos/model/filterSlice.ts diff --git a/src/features/filter-todos/index.ts b/src/features/filter-todos/index.ts index 2e248d3..cc5cc9a 100644 --- a/src/features/filter-todos/index.ts +++ b/src/features/filter-todos/index.ts @@ -1,2 +1,3 @@ -export { TodoFilter } from './ui/TodoFilter'; +export { filterChanged, filterReducer } from './model/filterSlice'; export type { FilterStatus } from './model/types'; +export { TodoFilter } from './ui/TodoFilter'; diff --git a/src/features/filter-todos/model/filterSlice.ts b/src/features/filter-todos/model/filterSlice.ts new file mode 100644 index 0000000..5a0bf8c --- /dev/null +++ b/src/features/filter-todos/model/filterSlice.ts @@ -0,0 +1,19 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import type { FilterStatus } from './types'; + +const initialState = { + status: 'all' as FilterStatus, +}; + +const filterSlice = createSlice({ + name: 'filter', + initialState, + reducers: { + filterChanged(state, action: PayloadAction) { + state.status = action.payload; + }, + }, +}); + +export const { filterChanged } = filterSlice.actions; +export const filterReducer = filterSlice.reducer; From 6a8741382fc59e7dd0285b99f9433493e0fd9631 Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Mon, 29 Jun 2026 20:06:09 +0200 Subject: [PATCH 07/18] feat(redux): move filter state into the store Register filterReducer in the store (state.filter) and replace HomePage's filter useState with useAppSelector(state => state.filter.status) + dispatch(filterChanged). Both todos and filter now live in Redux. --- src/app/store.ts | 4 +++- src/pages/home/ui/HomePage.tsx | 13 +++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/app/store.ts b/src/app/store.ts index 62e91f6..10e8bde 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -1,9 +1,11 @@ -import { configureStore } from '@reduxjs/toolkit'; import { todoReducer } from '@/entities/todo'; +import { filterReducer } from '@/features/filter-todos'; +import { configureStore } from '@reduxjs/toolkit'; export const store = configureStore({ reducer: { todos: todoReducer, + filter: filterReducer, }, }); diff --git a/src/pages/home/ui/HomePage.tsx b/src/pages/home/ui/HomePage.tsx index cd87cee..a69cc8d 100644 --- a/src/pages/home/ui/HomePage.tsx +++ b/src/pages/home/ui/HomePage.tsx @@ -1,22 +1,23 @@ import { useAppDispatch, useAppSelector } from '@/app/hooks'; import { todoAdded, todoDeleted, todoToggled } from '@/entities/todo'; import { AddTodoForm } from '@/features/add-todo'; -import { TodoFilter, type FilterStatus } from '@/features/filter-todos'; +import { filterChanged, TodoFilter, type FilterStatus } from '@/features/filter-todos'; import { TodoList } from '@/widgets/todo-list'; -import { useState } from 'react'; function HomePage() { const todos = useAppSelector((state) => state.todos.items); + const filterStatus = useAppSelector((state) => state.filter.status); const dispatch = useAppDispatch(); - const [filter, setFilter] = useState('all'); const addTodo = (text: string) => dispatch(todoAdded(text)); const toggleTodo = (id: string) => dispatch(todoToggled(id)); const deleteTodo = (id: string) => dispatch(todoDeleted(id)); + const setFilter = (status: FilterStatus) => dispatch(filterChanged(status)); + const filteredTodos = todos.filter((todo) => { - if (filter === 'active') return !todo.completed; - if (filter === 'completed') return todo.completed; + if (filterStatus === 'active') return !todo.completed; + if (filterStatus === 'completed') return todo.completed; return true; // 'all' case }); @@ -26,7 +27,7 @@ function HomePage() {

Todo list:

- +
From 20306d7392e14103a0cdcc3d3741fe56f644b608 Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Mon, 29 Jun 2026 20:06:09 +0200 Subject: [PATCH 08/18] docs(wiki): add Redux concepts page Add redux-concepts.md explaining actions, payload (incl. payload vs state shape and prepare), Immer as a standalone library embedded in RTK, and entity-vs-feature action ownership. Link it from README and the setup page. --- wiki/README.md | 1 + wiki/redux-concepts.md | 155 +++++++++++++++++++++++++++++++++++++++++ wiki/redux-setup.md | 2 + 3 files changed, 158 insertions(+) create mode 100644 wiki/redux-concepts.md diff --git a/wiki/README.md b/wiki/README.md index 14dae2d..665b632 100644 --- a/wiki/README.md +++ b/wiki/README.md @@ -20,3 +20,4 @@ For product documentation (user stories, feature specs), see [docs/](../docs/REA - [Feature-Sliced Design](./fsd-architecture.md) - [State management (overview & decision guide)](./state-management.md) - [Redux setup (RTK)](./redux-setup.md) +- [Redux/RTK concepts (actions, payload, Immer)](./redux-concepts.md) diff --git a/wiki/redux-concepts.md b/wiki/redux-concepts.md new file mode 100644 index 0000000..941be03 --- /dev/null +++ b/wiki/redux-concepts.md @@ -0,0 +1,155 @@ +# Redux/RTK: разбор понятий + +Концептуальная заметка-справочник: что такое action, payload, Immer и где живут actions. Дополняет [redux-setup.md](./redux-setup.md) (там — как мы настроили store; здесь — что означают понятия). + +## Action — запись о событии + +**Action — обычный JS-объект, описывающий что произошло.** Не команда «сделай», а факт «случилось» — как запись в журнале событий. + +```ts +{ type: 'filter/filterChanged', payload: 'active' } +// ▲ какое событие ▲ данные события +``` + +- **`type`** (обязательное) — строка-идентификатор события. RTK склеивает его из `name` слайса + имени reducer'а: `'filter'` + `'filterChanged'` → `'filter/filterChanged'`. +- Action **описывает прошлое**: `todoAdded`, `filterChanged` (что произошло), а не `addTodo`, `setFilter` (команды). Это стиль Flux — actions образуют журнал, по которому всегда можно понять «как state стал таким». +- Action **сериализуем** — plain-объект без функций/промисов/классов. Поэтому DevTools умеет их логировать, сохранять и проигрывать (time-travel). + +### Action creator + +Сам объекты руками не пишешь — `createSlice` генерирует **функции-создатели** с теми же именами, что у reducer'ов: + +```ts +filterChanged('active'); +// → { type: 'filter/filterChanged', payload: 'active' } +``` + +Один и тот же `filterChanged` — это и имя reducer'а (логика), и action creator (фабрика объекта-события). + +## Payload — данные события + +Одного `type` часто мало: «фильтр изменился» — а **на что**? Деталь события кладут в **`payload`**. + +- Для `filterChanged` payload = новый статус (строка). +- Для `todoAdded` payload = целый объект `Todo`. + +`payload` — это **конвенция имени поля** (стандарт Flux Standard Action: `{ type, payload, error?, meta? }`). Redux-ядро его не требует, но RTK и сообщество кладут данные именно туда. + +### Payload НЕ обязан повторять форму state + +Частая путаница. Форму **state** задаёт `initialState`; **payload** независим — несёт ровно то, что нужно для изменения. + +```ts +const initialState = { status: 'all' }; // state — объект { status } + +filterChanged(state, action: PayloadAction) { + state.status = action.payload; + // ▲ строка ▲ значит payload — строка, НЕ объект { status } +} +``` + +Ты присваиваешь payload в `state.status` (строковое поле), а не в `state` (объект). Значит payload = тип поля `status` = `FilterStatus` (строка). Чтобы поменять одно поле, обёртка `{ }` не нужна. + +### Чем регулируется тип payload + +Двумя согласованными вещами: + +1. **`PayloadAction`** — контракт типа. `PayloadAction` говорит «payload это `FilterStatus`». TypeScript заставит: задиспатчишь объект — ошибка. +2. **Как используешь в reducer'е** — `state.status = action.payload` должно соответствовать объявленному типу. + +### С `prepare` и без + +| | Аргумент action creator'а | Итоговый payload | +| ----------------------------------- | ------------------------- | --------------------------------------------------------------- | +| **без `prepare`** (`filterChanged`) | `'active'` | `'active'` — **аргумент = payload** напрямую | +| **с `prepare`** (`todoAdded`) | `'Buy milk'` | `{ id, text, completed }` — `prepare` **преобразовал** аргумент | + +Без `prepare`: что передал в creator — то и стало payload. С `prepare`: аргумент проходит через `prepare`, и payload может отличаться (там из текста собирали объект с `id`). + +## Immer — мутабельный синтаксис, иммутабельный результат + +### Это отдельная библиотека, не часть Redux + +Важно не путать: + +| Понятие | Что это | +| ---------------- | ------------------------------------------------------------------------------------------------------------- | +| **Immutability** | абстрактный **принцип** («не меняй старый объект, создавай новый») | +| **Immer** | конкретная **библиотека** (npm-пакет `immer`), помогающая достигать иммутабельности с мутабельным синтаксисом | +| **RTK** | **встроил** Immer и использует его в `createSlice` / `createReducer` | + +Immer существует сам по себе (его ядро — функция `produce(base, draft => {...})`) и работает где угодно: с `useState`, `useReducer`, в чистом JS. RTK просто вызывает его за тебя — поэтому в slice ты не импортируешь Immer, но «мутирующий» стиль работает. + +> Не «метод Redux» и не абстрактное понятие: абстрактное — это _immutability_, Immer — конкретный инструмент её реализации, встроенный в RTK. + +### Как работает + +`state` внутри reducer'а — **не настоящий объект, а draft (Proxy)**: + +1. Immer оборачивает текущий state в Proxy-черновик. +2. Ты «мутируешь» draft (`state.status = ...`) — Proxy **перехватывает и записывает** изменения, не трогая оригинал. +3. После reducer'а Immer строит **новый объект**, копируя только изменённые ветки, а нетронутые — переиспользует по ссылке (structural sharing → дёшево + работает reference equality). + +### Главное правило: мутируй ИЛИ возвращай, не одновременно + +```ts +// ✅ мутируешь draft (объекты/массивы) +filterChanged(state, action) { state.status = action.payload; } + +// ✅ возвращаешь новое (примитивы — мутировать нечего) +filterChanged(state, action) { return action.payload; } + +// ❌ и мутируешь, и возвращаешь — ошибка +filterChanged(state, action) { state.status = action.payload; return state; } +``` + +**Примитив (строку/число) нельзя «мутировать»** — у него нет свойств для перехвата Proxy. Поэтому для примитивного state единственный путь — `return`. Вот почему `state.status = ...` работает только когда state — объект. + +### Где Immer активен + +- В `createSlice` / `createReducer` (RTK) — по умолчанию. +- **Вне** RTK-reducer'ов (в компоненте, в обычном `useState`) Immer не работает — там мутация будет настоящим багом. «Мутируй смело» — только внутри reducer'ов slice. + +## Где живут actions: entity vs feature + +Почему `todoAdded` лежит в `entities/todo`, а не в `features/add-todo`? + +| | Что владеет | Пример | +| ---------------------- | ----------------------------------------------------------- | --------------------------------------------- | +| **entity `todo`** | **данные и их изменения** — массив todos и операции над ним | slice, `todoAdded`, `todoToggled`, тип `Todo` | +| **feature `add-todo`** | **взаимодействие пользователя** — форма, инпут, кнопка, UX | `AddTodoForm`, валидация, submit | + +`todoAdded` — это **переход состояния коллекции todos**, и принадлежит сущности: она хранит данные и знает какие изменения валидны. `features/add-todo` — это **форма**, которая просто **вызывает** действие сущности: + +```ts +// features/add-todo +import { todoAdded } from '@/entities/todo'; // берёт действие у сущности +dispatch(todoAdded(text)); // запускает его +``` + +Почему так: + +- **Сущность — единый источник истины о своих данных.** Все изменения todos (add/toggle/delete) в одном месте. Разбросать по фичам = размазать инварианты. +- **Фича — тонкий слой взаимодействия.** Оркеструет UX, но не владеет данными. +- Одни данные могут менять **несколько фич** (add, import, bulk-edit) — все дёргают actions сущности, данные не дублируются. + +Направление зависимостей: `features/add-todo` → **использует** → `entities/todo` (вниз по слоям — разрешено). + +> Нюанс: для сложной бизнес-логики фича может иметь _свои_ actions/thunks. Но CRUD над данными сущности — канонично в самой сущности. + +### Где это искать (без «копания») + +Actions и селекторы слайса лежат в его `model/` и экспортируются через `index.ts` (public API). Импортируешь из **одного места на слайс**: + +```ts +import { todoAdded, todoToggled, todoDeleted } from '@/entities/todo'; +``` + +Домен подсказывает где смотреть: про todos → `@/entities/todo`, про фильтр → `@/features/filter-todos`. Автокомплит на `@/entities/todo` покажет всю публичку. Искать по файлам не нужно — импортируешь из фасада. + +## См. также + +- [Redux setup (RTK)](./redux-setup.md) — как настроен store, хуки, Provider; и как **видеть стор статически** +- [Feature-Sliced Design](./fsd-architecture.md) — слои, слайсы, public API +- [Immutable state in React](./immutable-state.md) — принцип иммутабельности (то, что Immer прячет) +- [State management (обзор)](./state-management.md) — теория Flux, словарь терминов diff --git a/wiki/redux-setup.md b/wiki/redux-setup.md index e9f7510..f7d4de6 100644 --- a/wiki/redux-setup.md +++ b/wiki/redux-setup.md @@ -126,6 +126,8 @@ export const todoReducer = todoSlice.reducer; - **`prepare`** — колбэк который формирует payload до того как он попадёт в reducer. Здесь генерируем `id` внутри slice, чтобы компонент диспатчил просто `todoAdded(text)` и не знал как делаются id. Reducer и prepare — две части одного action creator. - Экспортируем **actions** (их будут диспатчить компоненты) и **reducer** (его подключает store). +> Глубже про action, payload, Immer и почему `todoAdded` лежит в entity, а не в feature — в [redux-concepts.md](./redux-concepts.md). + ## Где что лежит (FSD) | Что | Где | Почему | From e73ce90176e591cb2234bcd7a6a7dd28230c1f1a Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Tue, 30 Jun 2026 23:14:42 +0200 Subject: [PATCH 09/18] feat(redux): add todos and filter selectors Add selectTodos (entities/todo), selectFilterStatus and a memoized selectFilteredTodos (features/filter-todos, via createSelector composing selectTodos + selectFilterStatus). Expose them through the slice public APIs. --- src/entities/todo/index.ts | 5 +++-- src/entities/todo/model/selectors.ts | 4 ++++ src/features/filter-todos/index.ts | 1 + src/features/filter-todos/model/selectors.ts | 20 ++++++++++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 src/entities/todo/model/selectors.ts create mode 100644 src/features/filter-todos/model/selectors.ts diff --git a/src/entities/todo/index.ts b/src/entities/todo/index.ts index a1e2814..b2ff67e 100644 --- a/src/entities/todo/index.ts +++ b/src/entities/todo/index.ts @@ -1,3 +1,4 @@ -export { TodoItem } from './ui/TodoItem'; +export { selectTodos } from './model/selectors'; +export { todoAdded, todoDeleted, todoReducer, todoToggled } from './model/todoSlice'; export type { Todo } from './model/types'; -export { todoReducer, todoAdded, todoToggled, todoDeleted } from './model/todoSlice'; +export { TodoItem } from './ui/TodoItem'; diff --git a/src/entities/todo/model/selectors.ts b/src/entities/todo/model/selectors.ts new file mode 100644 index 0000000..0fab324 --- /dev/null +++ b/src/entities/todo/model/selectors.ts @@ -0,0 +1,4 @@ +import type { RootState } from '@/app/store'; + +// Basic selector: reads the todos slice's items out of the whole state. +export const selectTodos = (state: RootState) => state.todos.items; diff --git a/src/features/filter-todos/index.ts b/src/features/filter-todos/index.ts index cc5cc9a..0480035 100644 --- a/src/features/filter-todos/index.ts +++ b/src/features/filter-todos/index.ts @@ -1,3 +1,4 @@ export { filterChanged, filterReducer } from './model/filterSlice'; +export { selectFilteredTodos, selectFilterStatus } from './model/selectors'; export type { FilterStatus } from './model/types'; export { TodoFilter } from './ui/TodoFilter'; diff --git a/src/features/filter-todos/model/selectors.ts b/src/features/filter-todos/model/selectors.ts new file mode 100644 index 0000000..8895a66 --- /dev/null +++ b/src/features/filter-todos/model/selectors.ts @@ -0,0 +1,20 @@ +import type { RootState } from '@/app/store'; +import { selectTodos } from '@/entities/todo'; +import { createSelector } from '@reduxjs/toolkit'; + +// Basic selector: reads the filter slice's value out of the whole state. +export const selectFilterStatus = (state: RootState) => state.filter.status; + +export const selectFilteredTodos = createSelector( + [selectFilterStatus, selectTodos], + (filterStatus, todos) => { + switch (filterStatus) { + case 'completed': + return todos.filter((todo) => todo.completed); + case 'active': + return todos.filter((todo) => !todo.completed); + default: + return todos; + } + }, +); From da71825d840e551449fe309b57b3d2660c0ec0ab Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Tue, 30 Jun 2026 23:14:42 +0200 Subject: [PATCH 10/18] refactor(redux): derive visible todos via memoized selector Replace HomePage's inline todos filtering with useAppSelector(selectFilteredTodos) and read the filter via selectFilterStatus. The derivation logic and knowledge of store shape now live in the selectors, not the component. --- src/pages/home/ui/HomePage.tsx | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/pages/home/ui/HomePage.tsx b/src/pages/home/ui/HomePage.tsx index a69cc8d..c8314ab 100644 --- a/src/pages/home/ui/HomePage.tsx +++ b/src/pages/home/ui/HomePage.tsx @@ -1,26 +1,28 @@ import { useAppDispatch, useAppSelector } from '@/app/hooks'; import { todoAdded, todoDeleted, todoToggled } from '@/entities/todo'; import { AddTodoForm } from '@/features/add-todo'; -import { filterChanged, TodoFilter, type FilterStatus } from '@/features/filter-todos'; +import { + filterChanged, + selectFilteredTodos, + selectFilterStatus, + TodoFilter, + type FilterStatus, +} from '@/features/filter-todos'; import { TodoList } from '@/widgets/todo-list'; function HomePage() { - const todos = useAppSelector((state) => state.todos.items); - const filterStatus = useAppSelector((state) => state.filter.status); + const filterStatus = useAppSelector(selectFilterStatus); + const filteredTodos = useAppSelector(selectFilteredTodos); + const dispatch = useAppDispatch(); + // todo slice actions const addTodo = (text: string) => dispatch(todoAdded(text)); const toggleTodo = (id: string) => dispatch(todoToggled(id)); const deleteTodo = (id: string) => dispatch(todoDeleted(id)); - + // filter slice actions const setFilter = (status: FilterStatus) => dispatch(filterChanged(status)); - const filteredTodos = todos.filter((todo) => { - if (filterStatus === 'active') return !todo.completed; - if (filterStatus === 'completed') return todo.completed; - return true; // 'all' case - }); - return (
From bfcb1ed8e5a88df44980b58b89171efe4c4c1f96 Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Tue, 30 Jun 2026 23:14:42 +0200 Subject: [PATCH 11/18] docs(wiki): explain reducer-local vs selector-global state Add a section clarifying that the reducer's state is the local slice while a selector's state is the global RootState, where the mount key comes from, and field-vs-reducer anatomy. --- wiki/redux-concepts.md | 56 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/wiki/redux-concepts.md b/wiki/redux-concepts.md index 941be03..7ee61ad 100644 --- a/wiki/redux-concepts.md +++ b/wiki/redux-concepts.md @@ -110,6 +110,62 @@ filterChanged(state, action) { state.status = action.payload; return state; } - В `createSlice` / `createReducer` (RTK) — по умолчанию. - **Вне** RTK-reducer'ов (в компоненте, в обычном `useState`) Immer не работает — там мутация будет настоящим багом. «Мутируй смело» — только внутри reducer'ов slice. +## Локальный `state` редьюсера vs глобальный `state` селектора + +Частая путаница: в редьюсере пишем `state.status`, а в селекторе `state.filter.status`. Кажется несимметрично. Причина в том, что **это два разных `state` с разным масштабом**. + +``` +СЕЛЕКТОР видит ВЕСЬ стор: РЕДЬЮСЕР видит ТОЛЬКО свой слайс: +state = { state = { status: 'all' } + todos: { items: [...] }, ▲ весь state внутри filterChanged + filter: { status: 'all' } +} +``` + +- В **селекторе** `state` — глобальное дерево (`RootState`). До статуса: `state.filter.status`. +- В **редьюсере** `state` — уже сам слайс (`{ status }`). Redux передаёт каждому редьюсеру **только его кусок**, поэтому там `state.status`. Внутри редьюсера `state.filter` не существует — это было бы `state.filter.filter`. + +### Откуда берётся `filter` в `state.filter` + +Не из слайса, а из **ключа в `store.ts`**: + +```ts +// app/store.ts +reducer: { + filter: filterReducer, // ◄ слайс монтируется в state.filter +} +``` + +Ключ выбираешь ты. Назвал бы `mode: filterReducer` — селектор стал бы `state.mode.status`, **а редьюсер не изменился бы**. Точка монтирования задаётся в сторе, не в слайсе. (А `name: 'filter'` внутри `createSlice` влияет только на префикс `type` экшенов — `'filter/...'`, — не на место монтирования.) + +### Поле ≠ редьюсер + +``` +filter ← имя слайса (name) → префикс type экшенов + status ← ПОЛЕ состояния (из initialState) + filterChanged ← РЕДЬЮСЕР (обработчик, меняющий это поле) +``` + +`status` — поле данных; `filterChanged` — редьюсер. Это разные сущности. + +### Почему несимметрия — это правильно + +Редьюсер **локальный** (видит свой слайс), селектор **глобальный** (видит всё). Это фича: + +- Слайс самодостаточен и не знает где смонтирован — можно переставить под другой ключ в `store.ts`, не трогая редьюсер (поменяются только селекторы). +- Каждый редьюсер зависит только от своего куска, не от формы всего стора → слайсы собираются как кубики. + +### Цепочка целиком + +``` +store.ts: reducer: { filter: filterReducer } → слайс живёт в state.filter +слайс: name: 'filter', state: { status } → локальная форма { status } +редьюсер: state.status = action.payload → state = СЛАЙС, меняем поле +селектор: (state) => state.filter.status → state = ВЕСЬ стор, идём filter → status +``` + +> Редьюсер пишет «изнутри» слайса (`state.status`), селектор читает «снаружи» из глобального дерева (`state.filter.status`). Одни данные, разные точки обзора. `filter` в пути — ключ из `store.ts`, не часть слайса. + ## Где живут actions: entity vs feature Почему `todoAdded` лежит в `entities/todo`, а не в `features/add-todo`? From d26e4d148ab07805fc1d394672808647ec0bf8ff Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Sat, 4 Jul 2026 11:33:37 +0200 Subject: [PATCH 12/18] chore(redux): add store factory and Storybook Provider decorator Extract rootReducer via combineReducers and add setupStore(preloadedState) so tests and Storybook get isolated stores. Add a global Storybook decorator wrapping every story in a fresh Provider, seedable per-story via parameters.preloadedState. --- .storybook/preview.tsx | 17 +++++++++++++++++ src/app/store.ts | 26 +++++++++++++++++--------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 862c8df..b1b5f6d 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,7 +1,24 @@ import type { Preview } from '@storybook/react-vite'; +import { useState } from 'react'; +import { Provider } from 'react-redux'; import '../src/app/index.css'; +import { setupStore } from '../src/app/store'; const preview: Preview = { + // Every story runs inside a fresh, isolated Redux store, so connected + // components (those using useAppSelector/useAppDispatch) work and stories + // don't share state. A story can preload slice state via + // `parameters.preloadedState` to show a specific situation. + decorators: [ + (Story, context) => { + const [store] = useState(() => setupStore(context.parameters.preloadedState)); + return ( + + + + ); + }, + ], parameters: { controls: { matchers: { diff --git a/src/app/store.ts b/src/app/store.ts index 10e8bde..bd3d3f2 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -1,14 +1,22 @@ import { todoReducer } from '@/entities/todo'; import { filterReducer } from '@/features/filter-todos'; -import { configureStore } from '@reduxjs/toolkit'; +import { combineReducers, configureStore } from '@reduxjs/toolkit'; -export const store = configureStore({ - reducer: { - todos: todoReducer, - filter: filterReducer, - }, +const rootReducer = combineReducers({ + todos: todoReducer, + filter: filterReducer, }); -// Inferred from the store itself so the types always match the real reducers. -export type RootState = ReturnType; -export type AppDispatch = typeof store.dispatch; +// Factory so tests and Storybook can spin up isolated stores (optionally with +// preloaded state) instead of sharing the app singleton. +export const setupStore = (preloadedState?: Partial) => { + return configureStore({ reducer: rootReducer, preloadedState }); +}; + +// The one store the running app uses. +export const store = setupStore(); + +// Types are derived from the root reducer / store, not written by hand. +export type RootState = ReturnType; +export type AppStore = ReturnType; +export type AppDispatch = AppStore['dispatch']; From 36cb787b59c2faf8753cfd6ff57838131c8fadac Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Sat, 4 Jul 2026 11:33:38 +0200 Subject: [PATCH 13/18] refactor(redux): connect feature and widget components to the store AddTodoForm, TodoFilter and TodoList read/dispatch via typed hooks instead of props (TodoItem stays presentational). Stories use the Provider decorator and preloadedState. Removes prop drilling from these components. --- .../ui/AddTodoForm/AddTodoForm.stories.tsx | 56 ++++++++++--------- .../add-todo/ui/AddTodoForm/AddTodoForm.tsx | 15 +++-- .../ui/TodoFilter/TodoFilter.stories.tsx | 50 +++++------------ .../filter-todos/ui/TodoFilter/TodoFilter.tsx | 17 +++--- .../ui/TodoList/TodoList.stories.tsx | 29 ++-------- .../todo-list/ui/TodoList/TodoList.tsx | 17 +++--- 6 files changed, 77 insertions(+), 107 deletions(-) diff --git a/src/features/add-todo/ui/AddTodoForm/AddTodoForm.stories.tsx b/src/features/add-todo/ui/AddTodoForm/AddTodoForm.stories.tsx index 0d3f017..109abac 100644 --- a/src/features/add-todo/ui/AddTodoForm/AddTodoForm.stories.tsx +++ b/src/features/add-todo/ui/AddTodoForm/AddTodoForm.stories.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useAppSelector } from '@/app/hooks'; +import { selectTodos } from '@/entities/todo'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn, userEvent, within } from 'storybook/test'; +import { expect, userEvent, within } from 'storybook/test'; import { AddTodoForm } from './AddTodoForm'; const meta: Meta = { @@ -10,42 +11,43 @@ const meta: Meta = { layout: 'padded', }, tags: ['autodocs'], - args: { - onAdd: fn(), - }, }; export default meta; type Story = StoryObj; -export const Interactive: Story = { - render: (args) => { - const [todos, setTodos] = useState([]); - - const handleAdd = (text: string) => { - args.onAdd(text); // вызовем из args — попадёт в Actions panel - setTodos((prev) => [...prev, text]); - }; +// The form has no props anymore — it dispatches todoAdded to the store itself. +export const Default: Story = {}; - return ( -
- -
    - {todos.map((t, i) => ( -
  • • {t}
  • - ))} -
-
- ); - }, +// Small reader showing what the form dispatched into the (per-story) store. +const AddedTodoList = () => { + const todos = useAppSelector(selectTodos); + return ( +
    + {todos.map((todo) => ( +
  • • {todo.text}
  • + ))} +
+ ); }; -export const Empty: Story = {}; +export const WithList: Story = { + render: () => ( +
+ + +
+ ), +}; -export const Filled: Story = { +export const AddsTodo: Story = { + ...WithList, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const input = canvas.getByPlaceholderText('Add a new todo'); + const input = canvas.getByPlaceholderText('Add a new todo task'); await userEvent.type(input, 'Buy milk'); + await userEvent.click(canvas.getByRole('button', { name: 'Add' })); + await expect(input).toHaveValue(''); // input cleared after submit + await expect(canvas.getByText('• Buy milk')).toBeInTheDocument(); // reached the store }, }; diff --git a/src/features/add-todo/ui/AddTodoForm/AddTodoForm.tsx b/src/features/add-todo/ui/AddTodoForm/AddTodoForm.tsx index 670509f..7d25b53 100644 --- a/src/features/add-todo/ui/AddTodoForm/AddTodoForm.tsx +++ b/src/features/add-todo/ui/AddTodoForm/AddTodoForm.tsx @@ -1,17 +1,20 @@ +import { useAppDispatch } from '@/app/hooks'; +import { todoAdded } from '@/entities/todo'; import { Button, Input } from '@/shared/ui'; import { useState, type SubmitEvent } from 'react'; -interface AddTodoFormProps { - onAdd: (text: string) => void; -} - -export const AddTodoForm = ({ onAdd }: AddTodoFormProps) => { +export const AddTodoForm = () => { const [text, setText] = useState(''); + + const dispatch = useAppDispatch(); + + const addTodo = (text: string) => dispatch(todoAdded(text)); const trimmedText = text.trim(); + const handleSubmit = (e: SubmitEvent) => { e.preventDefault(); if (trimmedText === '') return; - onAdd(trimmedText); + addTodo(trimmedText); setText(''); }; diff --git a/src/features/filter-todos/ui/TodoFilter/TodoFilter.stories.tsx b/src/features/filter-todos/ui/TodoFilter/TodoFilter.stories.tsx index a4bac94..f7dfc78 100644 --- a/src/features/filter-todos/ui/TodoFilter/TodoFilter.stories.tsx +++ b/src/features/filter-todos/ui/TodoFilter/TodoFilter.stories.tsx @@ -1,54 +1,34 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useArgs } from 'storybook/preview-api'; -import { fn } from 'storybook/test'; -import type { TodoFilterProps } from './TodoFilter'; +import { expect, userEvent, within } from 'storybook/test'; import { TodoFilter } from './TodoFilter'; const meta = { title: 'Todos/TodoFilter', component: TodoFilter, tags: ['autodocs'], - args: { - onChange: fn(), - }, - argTypes: { - value: { - control: 'radio', - options: ['all', 'active', 'completed'], - }, - }, } satisfies Meta; export default meta; type Story = StoryObj; -export const Default: Story = { - args: { - value: 'all', - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - return ( - { - updateArgs({ value: next }); - args.onChange?.(next); // чтобы Actions panel тоже видел вызов - }} - /> - ); - }, -}; +// Empty store → the filter defaults to 'all'. +export const Default: Story = {}; +// Preload the filter slice to show a specific selected state (no props involved). export const ActiveSelected: Story = { - args: { - value: 'active', - }, + parameters: { preloadedState: { filter: { status: 'active' } } }, }; export const CompletedSelected: Story = { - args: { - value: 'completed', + parameters: { preloadedState: { filter: { status: 'completed' } } }, +}; + +// Clicking a button dispatches filterChanged; the component reflects it itself. +export const SelectsFilter: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const activeBtn = canvas.getByRole('button', { name: 'Active' }); + await userEvent.click(activeBtn); + await expect(activeBtn).toHaveAttribute('aria-pressed', 'true'); // компонент сам отразил }, }; diff --git a/src/features/filter-todos/ui/TodoFilter/TodoFilter.tsx b/src/features/filter-todos/ui/TodoFilter/TodoFilter.tsx index ab7694c..eecd690 100644 --- a/src/features/filter-todos/ui/TodoFilter/TodoFilter.tsx +++ b/src/features/filter-todos/ui/TodoFilter/TodoFilter.tsx @@ -1,27 +1,28 @@ +import { useAppDispatch, useAppSelector } from '@/app/hooks'; +import { filterChanged } from '@/features/filter-todos/model/filterSlice'; +import { selectFilterStatus } from '@/features/filter-todos/model/selectors'; import type { FilterStatus } from '@/features/filter-todos/model/types'; -export interface TodoFilterProps { - value: FilterStatus; - onChange: (filter: FilterStatus) => void; -} - const OPTIONS: { value: FilterStatus; label: string }[] = [ { value: 'all', label: 'All' }, { value: 'active', label: 'Active' }, { value: 'completed', label: 'Completed' }, ]; -export const TodoFilter = ({ value, onChange }: TodoFilterProps) => { +export const TodoFilter = () => { + const filterStatus = useAppSelector(selectFilterStatus); + const dispatch = useAppDispatch(); + const setFilter = (status: FilterStatus) => dispatch(filterChanged(status)); return (
{OPTIONS.map((option) => { - const isActive = value === option.value; + const isActive = filterStatus === option.value; return (
), ], - args: { - onToggle: fn(), - onDelete: fn(), - }, }; export default meta; type Story = StoryObj; export const Default: Story = { - args: { - todos: buildTodos(3), - }, + parameters: { preloadedState: { todos: { items: buildTodos(3) } } }, }; -export const Empty: Story = { - args: { - todos: [], - }, -}; +export const Empty: Story = {}; export const Single: Story = { - args: { - todos: buildTodos(1), - }, + parameters: { preloadedState: { todos: { items: buildTodos(1) } } }, }; export const AllCompleted: Story = { - args: { - todos: buildTodos(3, { completed: true }), - }, + parameters: { preloadedState: { todos: { items: buildTodos(3, { completed: true }) } } }, }; export const Random: Story = { - args: { - todos: buildTodos(3, { mixed: true }), - }, + parameters: { preloadedState: { todos: { items: buildTodos(3, { mixed: true }) } } }, }; diff --git a/src/widgets/todo-list/ui/TodoList/TodoList.tsx b/src/widgets/todo-list/ui/TodoList/TodoList.tsx index 44e2786..43d79f1 100644 --- a/src/widgets/todo-list/ui/TodoList/TodoList.tsx +++ b/src/widgets/todo-list/ui/TodoList/TodoList.tsx @@ -1,19 +1,20 @@ -import { TodoItem, type Todo } from '@/entities/todo'; +import { useAppDispatch, useAppSelector } from '@/app/hooks'; +import { TodoItem, todoDeleted, todoToggled } from '@/entities/todo'; +import { selectFilteredTodos } from '@/features/filter-todos'; -interface TodoListProps { - todos: Todo[]; - onToggle: (id: string) => void; - onDelete: (id: string) => void; -} +export const TodoList = () => { + const todos = useAppSelector(selectFilteredTodos); + const dispatch = useAppDispatch(); + const toggleTodo = (id: string) => dispatch(todoToggled(id)); + const deleteTodo = (id: string) => dispatch(todoDeleted(id)); -export const TodoList = ({ todos, onToggle, onDelete }: TodoListProps) => { if (todos.length === 0) { return

No tasks yet, time to relax 😉

; } return (
    {todos.map((todo) => ( - + ))}
); From 6a7162cb13e9124b85a66c876998d6626bc9b4c5 Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Sat, 4 Jul 2026 11:33:49 +0200 Subject: [PATCH 14/18] refactor(redux): reduce HomePage to pure composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With features/widget connected to the store, HomePage no longer owns state, handlers or selectors — it just composes AddTodoForm, TodoFilter and TodoList with no props. --- src/pages/home/ui/HomePage.tsx | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/src/pages/home/ui/HomePage.tsx b/src/pages/home/ui/HomePage.tsx index c8314ab..15ad054 100644 --- a/src/pages/home/ui/HomePage.tsx +++ b/src/pages/home/ui/HomePage.tsx @@ -1,36 +1,16 @@ -import { useAppDispatch, useAppSelector } from '@/app/hooks'; -import { todoAdded, todoDeleted, todoToggled } from '@/entities/todo'; import { AddTodoForm } from '@/features/add-todo'; -import { - filterChanged, - selectFilteredTodos, - selectFilterStatus, - TodoFilter, - type FilterStatus, -} from '@/features/filter-todos'; +import { TodoFilter } from '@/features/filter-todos'; import { TodoList } from '@/widgets/todo-list'; function HomePage() { - const filterStatus = useAppSelector(selectFilterStatus); - const filteredTodos = useAppSelector(selectFilteredTodos); - - const dispatch = useAppDispatch(); - - // todo slice actions - const addTodo = (text: string) => dispatch(todoAdded(text)); - const toggleTodo = (id: string) => dispatch(todoToggled(id)); - const deleteTodo = (id: string) => dispatch(todoDeleted(id)); - // filter slice actions - const setFilter = (status: FilterStatus) => dispatch(filterChanged(status)); - return (

Todo list:

- - - + + +
From 7801a02dd4be9da28e8d7e5a74bfd6c1fab7532e Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Sat, 4 Jul 2026 11:33:49 +0200 Subject: [PATCH 15/18] docs(wiki): selectors, connected-vs-presentational, Storybook+Redux Document selectors (basic vs derived, createSelector memoization, FSD placement) in redux-concepts. Add connected-vs-presentational layer guidance and a Storybook-for-connected-components section (setupStore, Provider decorator, preloadedState) to redux-setup. --- wiki/redux-concepts.md | 49 ++++++++++++++++++++++++++++++++++++++++++ wiki/redux-setup.md | 42 ++++++++++++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/wiki/redux-concepts.md b/wiki/redux-concepts.md index 7ee61ad..1cd7008 100644 --- a/wiki/redux-concepts.md +++ b/wiki/redux-concepts.md @@ -166,6 +166,55 @@ store.ts: reducer: { filter: filterReducer } → слайс живёт в st > Редьюсер пишет «изнутри» слайса (`state.status`), селектор читает «снаружи» из глобального дерева (`state.filter.status`). Одни данные, разные точки обзора. `filter` в пути — ключ из `store.ts`, не часть слайса. +## Селекторы + +**Селектор — функция, которая берёт `state` и возвращает кусок или вычисленное из него.** Ты их уже пишешь инлайн: `useAppSelector((state) => state.todos.items)`. «Именованный» селектор — та же функция, вынесенная рядом со слайсом: переиспользуемая, тестируемая, инкапсулирующая форму стора. + +### Базовые vs производные + +| Вид | Что делает | Пример | +| --------------- | --------------------------- | -------------------------------------------------------------- | +| **Базовый** | просто читает кусок | `selectTodos = (s) => s.todos.items` | +| **Производный** | комбинирует и **вычисляет** | `selectVisibleTodos` = todos + filter → отфильтрованный список | + +### Зачем выносить + +- **Инкапсуляция формы стора** — компонент просит `selectVisibleTodos`, а не лезет в `state.todos.items`. Поменяется форма — правишь селектор, а не каждый компонент. +- **Переиспользование** — логика деривации в одном месте. +- **Тестируемость** — чистая функция, тестируется без React. +- **Мемоизация** — см. ниже, это главная техническая причина для производных. + +### Мемоизация и `createSelector` + +`useSelector` после **каждого** action заново вызывает селектор и сравнивает результат с прошлым по ссылке (`===`). Если ссылка та же — ререндера нет. + +Проблема с производным селектором: + +```ts +useAppSelector((state) => state.todos.items.filter(...)) +// ▲ .filter() создаёт НОВЫЙ массив каждый вызов +``` + +Новый массив → новая ссылка → `===` всегда false → компонент ререндерится **на любой action**, даже несвязанный. `createSelector` (из Reselect, встроен в RTK) **мемоизирует**: + +```ts +export const selectVisibleTodos = createSelector( + [selectTodos, selectFilterStatus], // входные селекторы + (todos, status) => { + /* ...фильтрация... */ + }, // результирующая функция +); +``` + +- **Входные селекторы** достают куски из `state`. +- **Результирующая функция** получает **выходы** входных (не `state`) и комбинирует. +- **Кэш по входам**: пока `todos` и `status` те же — возвращается **та же ссылка**, `.filter()` не пересчитывается, ререндера нет. + +### Где лежат по FSD + +- **Базовые** — со своим слайсом: `selectTodos` → `entities/todo/model/selectors.ts`, `selectFilterStatus` → `features/filter-todos/model/selectors.ts`. +- **Производный `selectVisibleTodos`** комбинирует todos (entity) + filter (feature). Кладём в **`features/filter-todos`** — фильтрация её смысл, — а `selectTodos` она берёт из `@/entities/todo` (импорт вниз, разрешён). Так фича владеет «отфильтрованным видом», не дублируя данные. + ## Где живут actions: entity vs feature Почему `todoAdded` лежит в `entities/todo`, а не в `features/add-todo`? diff --git a/wiki/redux-setup.md b/wiki/redux-setup.md index f7d4de6..3b887c9 100644 --- a/wiki/redux-setup.md +++ b/wiki/redux-setup.md @@ -166,11 +166,49 @@ export const todoReducer = todoSlice.reducer; > Это честный trade-off: Redux проигрывает в «обзорности с одного взгляда», выигрывает в модульности. В главе сравнения перепишем тот же стор на Zustand и почувствуем разницу вживую. +## Connected vs presentational: где подключать стор + +Не каждый компонент должен ходить в стор. Граница по FSD: + +| Слой | Роль | Стор? | +| ------------------------------------ | -------------------------- | --------------------------------- | +| `shared/ui` (Button, Input) | переиспользуемые примитивы | **нет** — только props | +| `entities` (TodoItem) | презентация сущности | **нет** — только props | +| `features` (AddTodoForm, TodoFilter) | взаимодействия | **да** — хуки | +| `widgets` (TodoList) | композиция | **да** — хуки | +| `pages` (HomePage) | сборка | обычно да, либо просто композиция | + +**Принцип:** `shared/ui` и `entities` — **презентационные** (тупые). Их легко переиспользовать и сторить в изоляции. `features`/`widgets` — **connected** (клей приложения). `TodoItem` остаётся тупым: виджет `TodoList` владеет диспатчем и передаёт ему `onToggle`/`onDelete` колбэками, чтобы сущность оставалась гибкой. + +**Trade-off:** подключив компоненты, мы убрали prop drilling (`HomePage` схлопнулся до композиции) — но связали компоненты со стором: их сложнее переиспользовать и тестировать/сторить в изоляции. + +**Сигнал:** если тащить Redux в Storybook стало больно — вероятно, подключаешь стор **слишком низко**. Держи переиспользуемые примитивы презентационными. + +> В бигтехе так и делают: connected — `features`/`widgets`/`pages`; переиспользуемый UI-kit и entity-представления остаются на props. Строгий «container/presentational» split после хуков стал необязательным, но правило «reusable = dumb» осталось универсальным. + +## Storybook для connected-компонентов + +Как только компонент вызывает `useAppSelector`/`useAppDispatch`, его stories упадут вне ``. Решение (см. [preview.tsx](../.storybook/preview.tsx)): + +1. **Фабрика `setupStore`** (в [store.ts](../src/app/store.ts)) — свежий изолированный стор на каждую story, чтобы они не делили состояние. +2. **Глобальный Provider-декоратор** оборачивает **каждую** story: ``. Цена платится один раз здесь. +3. **`parameters.preloadedState`** — story засевает стор нужным состоянием: + ```tsx + export const ActiveSelected: Story = { + parameters: { preloadedState: { filter: { status: 'active' } } }, + }; + ``` + Состояние connected-компонента задаётся через **стор**, а не через `args` (пропсов-то нет). + +**Реальный стор, не mock.** Современная рекомендация Redux — тестировать/сторить с настоящим стором и реальными редьюсерами (через `setupStore`), а не с фейковым `redux-mock-store` (он устарел). + +> `play`-функции (story-as-test) и как они гоняются в CI — в [storybook.md](./storybook.md). Здесь только Redux-часть: как дать connected-компоненту стор. + ## Что дальше -Store подключён к UI: `HomePage` читает `todos` через `useAppSelector` и диспатчит `todoAdded` / `todoToggled` / `todoDeleted` вместо `setState`. Источник истины для todos теперь в Redux. +Готово: `todos` и `filter` — в своих слайсах, `visibleTodos` — мемоизированный селектор, prop drilling убран, `HomePage` — чистая композиция. Стор — единственный источник истины, компоненты `features`/`widgets` подключены к нему напрямую. -`filter` пока остаётся в локальном `useState` — намеренный контраст (todos через store, filter через props на одном экране). Следующие шаги: перенести `filter` в свой slice, выразить `visibleTodos` через селекторы и затем убрать prop drilling (компоненты обращаются к store напрямую). +Дальше по главе: честный разбор **trade-offs** (что улучшилось, что усложнилось по сравнению с `useState`), затем сравнение того же домена на **Zustand** и **MobX**. ## См. также From 4665194d9e9b1203ec684ff427d57db57245e469 Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Sat, 4 Jul 2026 11:53:05 +0200 Subject: [PATCH 16/18] docs(wiki): add state managers comparison (Redux vs Zustand vs MobX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare the same todo domain across Redux (RTK), Zustand and MobX with illustrative snippets, a differences table and when-to-pick guidance. Zustand/MobX are not implemented in the project — documentation only. --- wiki/README.md | 1 + wiki/state-managers-comparison.md | 150 ++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 wiki/state-managers-comparison.md diff --git a/wiki/README.md b/wiki/README.md index 665b632..2442adb 100644 --- a/wiki/README.md +++ b/wiki/README.md @@ -21,3 +21,4 @@ For product documentation (user stories, feature specs), see [docs/](../docs/REA - [State management (overview & decision guide)](./state-management.md) - [Redux setup (RTK)](./redux-setup.md) - [Redux/RTK concepts (actions, payload, Immer)](./redux-concepts.md) +- [State managers compared (Redux vs Zustand vs MobX)](./state-managers-comparison.md) diff --git a/wiki/state-managers-comparison.md b/wiki/state-managers-comparison.md new file mode 100644 index 0000000..5362ae2 --- /dev/null +++ b/wiki/state-managers-comparison.md @@ -0,0 +1,150 @@ +# Сравнение: Redux vs Zustand vs MobX + +Один и тот же todo-домен на трёх популярных state-менеджерах. Цель — прочувствовать разницу подходов, **не** реализуя каждый в проекте. Redux (RTK) мы построили по-настоящему (см. [redux-setup.md](./redux-setup.md)); Zustand и MobX здесь показаны иллюстративными сниппетами. + +> Высокоуровневый ландшафт и дерево решений — в [state-management.md](./state-management.md). Здесь — код-левел контраст на знакомом домене. + +## Redux (RTK) — что мы построили + +Разнесено по ролям: slice (state + reducers через Immer), actions, селекторы, store, Provider, типизированные хуки. + +```ts +// entities/todo/model/todoSlice.ts +const todoSlice = createSlice({ + name: 'todos', + initialState: { items: [] as Todo[] }, + reducers: { + todoAdded: { + reducer: (s, a: PayloadAction) => void s.items.push(a.payload), // Immer + prepare: (text: string) => ({ payload: { id: crypto.randomUUID(), text, completed: false } }), + }, + todoToggled: (s, a: PayloadAction) => { + const t = s.items.find((x) => x.id === a.payload); + if (t) t.completed = !t.completed; + }, + }, +}); +``` + +```tsx +// компонент +const items = useAppSelector(selectTodos); +const dispatch = useAppDispatch(); +dispatch(todoAdded('Buy milk')); +``` + +**Профиль:** явные именованные actions → полная трассируемость и time-travel; store через ``; иммутабельность обязательна (Immer прячет boilerplate); больше всего церемонии. + +## Zustand — «useState, доступный отовсюду» + +Весь стор — один `create()`: state и «экшены» (просто функции) вместе. Нет Provider, нет отдельных reducer'ов/action-объектов. Хук **сам** и есть стор. + +```ts +import { create } from 'zustand'; + +interface TodoState { + items: Todo[]; + addTodo: (text: string) => void; + toggleTodo: (id: string) => void; + deleteTodo: (id: string) => void; +} + +export const useTodoStore = create((set) => ({ + items: [], + addTodo: (text) => + set((s) => ({ items: [...s.items, { id: crypto.randomUUID(), text, completed: false }] })), + toggleTodo: (id) => + set((s) => ({ + items: s.items.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)), + })), + deleteTodo: (id) => set((s) => ({ items: s.items.filter((t) => t.id !== id) })), +})); +``` + +```tsx +// компонент — селектор прямо в хуке, без Provider +const items = useTodoStore((s) => s.items); +const addTodo = useTodoStore((s) => s.addTodo); +addTodo('Buy milk'); +``` + +**Профиль:** минимум boilerplate; нет Provider; селекторы встроены в хук; иммутабельность **вручную** (spread) или через immer-middleware; нет action-объектов → трассируемость ниже (DevTools есть через middleware). Отлично для малых/средних приложений. + +## MobX — реактивные observable + +Совсем другая парадигма: объявляешь observable-объект, **мутируешь его напрямую**, а компоненты-`observer` перерисовываются автоматически при изменении того, что читают. Никаких actions/reducers/селекторов-функций. + +```ts +import { makeAutoObservable } from 'mobx'; + +class TodoStore { + items: Todo[] = []; + + constructor() { + makeAutoObservable(this); + } + + addTodo(text: string) { + this.items.push({ id: crypto.randomUUID(), text, completed: false }); // НАСТОЯЩАЯ мутация + } + + toggleTodo(id: string) { + const t = this.items.find((x) => x.id === id); + if (t) t.completed = !t.completed; + } + + // производное состояние — computed-геттер (кэшируется автоматически) + get activeCount() { + return this.items.filter((t) => !t.completed).length; + } +} + +export const todoStore = new TodoStore(); +``` + +```tsx +import { observer } from 'mobx-react-lite'; + +const TodoList = observer(() => ( +
    + {todoStore.items.map((t) => ( +
  • {t.text}
  • + ))} +
+)); +``` + +**Профиль:** мутируешь напрямую (иммутабельность **не** нужна); `computed`-геттеры = derived state (как селекторы, но авто-кэш и авто-подписка); компоненты оборачиваются в `observer`. Меняет «явный лог что произошло» на эргономику. Часто в enterprise / Angular-style командах. + +## Ключевые отличия + +| | **Redux (RTK)** | **Zustand** | **MobX** | +| ----------------------- | ----------------------------- | ---------------------- | ---------------------------------- | +| Парадигма | Flux (явные actions) | Flux-lite | реактивные observable | +| Определение стора | slices + `configureStore` | один `create()` | класс + `makeAutoObservable` | +| Provider | **да** | нет | нет | +| Обновление | `dispatch(action)` → reducer | `set((s) => …)` | **мутируешь** `this.x = …` | +| Чтение в компоненте | `useSelector(selector)` | `useStore(selector)` | доступ к observable + `observer()` | +| Derived state | селекторы / `createSelector` | селектор-функция | `computed`-геттеры (авто) | +| Иммутабельность | обязательна (Immer) | вручную (или immer-mw) | **не нужна** (мутации) | +| Трассируемость / лог | высокая (именованные actions) | низкая | низкая | +| DevTools | отличные | хорошие (middleware) | хорошие | +| Async | thunks / RTK Query | async-функции в сторе | actions / flows | +| Boilerplate | больше всего | минимум | мало | +| Сдвиг ментальной модели | средний | маленький | большой | + +## Когда что + +- **Redux (RTK)** — большие приложения; нужны предсказуемость, time-travel, middleware, единый строгий поток; командный стандарт; максимум вакансий. Цена — церемония. +- **Zustand** — малые/средние; хочется «глобального useState» без Provider и boilerplate. Нет тяжёлого async-инструментария RTK Query. +- **MobX** — предпочитаешь реактивную/ООП-модель и эргономику мутаций; не нужен явный журнал действий. Другая ментальная модель — закладывай время на переучивание. + +## Мысль на вынос + +Все три решают одну задачу — сквозной shared state — но с разной **философией**: Redux делает поток **явным** (ценой церемонии), Zustand — **минимальным**, MobX — **реактивным** (ценой другой модели мышления). Изучив Redux как самый эксплицитный, остальные читаются быстро: узнаёшь тот же паттерн в более компактной форме. + +## См. также + +- [State management (обзор и дерево решений)](./state-management.md) +- [Redux setup (RTK)](./redux-setup.md) — что мы построили по-настоящему +- [Redux/RTK: разбор понятий](./redux-concepts.md) — actions, payload, Immer, селекторы From 9e37f7900c10569e2b6d8460e09356db5591baec Mon Sep 17 00:00:00 2001 From: Demonkratiy Date: Wed, 19 Aug 2026 18:21:26 +0200 Subject: [PATCH 17/18] refactor(widgets): consolidate todo logic into TodoWidget facade hook Replace the three separately-connected components (AddTodoForm, TodoFilter, TodoList) with a single connected TodoWidget driven by one facade hook (useTodoWidget). AddTodoForm/TodoFilter are presentational again (props-only); widgets/todo-list is removed. --- .../ui/AddTodoForm/AddTodoForm.stories.tsx | 33 ++-------- .../add-todo/ui/AddTodoForm/AddTodoForm.tsx | 14 ++-- .../ui/TodoFilter/TodoFilter.stories.tsx | 15 ++--- .../filter-todos/ui/TodoFilter/TodoFilter.tsx | 17 +++-- src/pages/home/ui/HomePage.tsx | 11 +--- src/widgets/todo-list/index.ts | 1 - .../ui/TodoList/TodoList.stories.tsx | 51 --------------- .../todo-list/ui/TodoList/TodoList.tsx | 21 ------ src/widgets/todo-list/ui/TodoList/index.ts | 1 - src/widgets/todo/index.ts | 1 + src/widgets/todo/model/useTodoWidget.ts | 23 +++++++ .../todo/ui/TodoWidget/TodoWidget.stories.tsx | 64 +++++++++++++++++++ src/widgets/todo/ui/TodoWidget/TodoWidget.tsx | 27 ++++++++ src/widgets/todo/ui/TodoWidget/index.ts | 1 + 14 files changed, 144 insertions(+), 136 deletions(-) delete mode 100644 src/widgets/todo-list/index.ts delete mode 100644 src/widgets/todo-list/ui/TodoList/TodoList.stories.tsx delete mode 100644 src/widgets/todo-list/ui/TodoList/TodoList.tsx delete mode 100644 src/widgets/todo-list/ui/TodoList/index.ts create mode 100644 src/widgets/todo/index.ts create mode 100644 src/widgets/todo/model/useTodoWidget.ts create mode 100644 src/widgets/todo/ui/TodoWidget/TodoWidget.stories.tsx create mode 100644 src/widgets/todo/ui/TodoWidget/TodoWidget.tsx create mode 100644 src/widgets/todo/ui/TodoWidget/index.ts diff --git a/src/features/add-todo/ui/AddTodoForm/AddTodoForm.stories.tsx b/src/features/add-todo/ui/AddTodoForm/AddTodoForm.stories.tsx index 109abac..84ee90d 100644 --- a/src/features/add-todo/ui/AddTodoForm/AddTodoForm.stories.tsx +++ b/src/features/add-todo/ui/AddTodoForm/AddTodoForm.stories.tsx @@ -1,7 +1,5 @@ -import { useAppSelector } from '@/app/hooks'; -import { selectTodos } from '@/entities/todo'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, userEvent, within } from 'storybook/test'; +import { expect, fn, userEvent, within } from 'storybook/test'; import { AddTodoForm } from './AddTodoForm'; const meta: Meta = { @@ -11,43 +9,22 @@ const meta: Meta = { layout: 'padded', }, tags: ['autodocs'], + args: { onAdd: fn() }, }; export default meta; type Story = StoryObj; -// The form has no props anymore — it dispatches todoAdded to the store itself. +// Presentational again — onAdd is a prop, the widget owns the dispatch. export const Default: Story = {}; -// Small reader showing what the form dispatched into the (per-story) store. -const AddedTodoList = () => { - const todos = useAppSelector(selectTodos); - return ( -
    - {todos.map((todo) => ( -
  • • {todo.text}
  • - ))} -
- ); -}; - -export const WithList: Story = { - render: () => ( -
- - -
- ), -}; - export const AddsTodo: Story = { - ...WithList, - play: async ({ canvasElement }) => { + play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); const input = canvas.getByPlaceholderText('Add a new todo task'); await userEvent.type(input, 'Buy milk'); await userEvent.click(canvas.getByRole('button', { name: 'Add' })); await expect(input).toHaveValue(''); // input cleared after submit - await expect(canvas.getByText('• Buy milk')).toBeInTheDocument(); // reached the store + await expect(args.onAdd).toHaveBeenCalledWith('Buy milk'); }, }; diff --git a/src/features/add-todo/ui/AddTodoForm/AddTodoForm.tsx b/src/features/add-todo/ui/AddTodoForm/AddTodoForm.tsx index 7d25b53..4a6494a 100644 --- a/src/features/add-todo/ui/AddTodoForm/AddTodoForm.tsx +++ b/src/features/add-todo/ui/AddTodoForm/AddTodoForm.tsx @@ -1,20 +1,18 @@ -import { useAppDispatch } from '@/app/hooks'; -import { todoAdded } from '@/entities/todo'; import { Button, Input } from '@/shared/ui'; import { useState, type SubmitEvent } from 'react'; -export const AddTodoForm = () => { - const [text, setText] = useState(''); - - const dispatch = useAppDispatch(); +interface AddTodoFormProps { + onAdd: (text: string) => void; +} - const addTodo = (text: string) => dispatch(todoAdded(text)); +export const AddTodoForm = ({ onAdd }: AddTodoFormProps) => { + const [text, setText] = useState(''); const trimmedText = text.trim(); const handleSubmit = (e: SubmitEvent) => { e.preventDefault(); if (trimmedText === '') return; - addTodo(trimmedText); + onAdd(trimmedText); setText(''); }; diff --git a/src/features/filter-todos/ui/TodoFilter/TodoFilter.stories.tsx b/src/features/filter-todos/ui/TodoFilter/TodoFilter.stories.tsx index f7dfc78..e29ab73 100644 --- a/src/features/filter-todos/ui/TodoFilter/TodoFilter.stories.tsx +++ b/src/features/filter-todos/ui/TodoFilter/TodoFilter.stories.tsx @@ -1,34 +1,33 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, userEvent, within } from 'storybook/test'; +import { expect, fn, userEvent, within } from 'storybook/test'; import { TodoFilter } from './TodoFilter'; const meta = { title: 'Todos/TodoFilter', component: TodoFilter, tags: ['autodocs'], + args: { status: 'all', onChange: fn() }, } satisfies Meta; export default meta; type Story = StoryObj; -// Empty store → the filter defaults to 'all'. export const Default: Story = {}; -// Preload the filter slice to show a specific selected state (no props involved). export const ActiveSelected: Story = { - parameters: { preloadedState: { filter: { status: 'active' } } }, + args: { status: 'active' }, }; export const CompletedSelected: Story = { - parameters: { preloadedState: { filter: { status: 'completed' } } }, + args: { status: 'completed' }, }; -// Clicking a button dispatches filterChanged; the component reflects it itself. +// Controlled component: it reports the click, the widget decides the next status. export const SelectsFilter: Story = { - play: async ({ canvasElement }) => { + play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); const activeBtn = canvas.getByRole('button', { name: 'Active' }); await userEvent.click(activeBtn); - await expect(activeBtn).toHaveAttribute('aria-pressed', 'true'); // компонент сам отразил + await expect(args.onChange).toHaveBeenCalledWith('active'); }, }; diff --git a/src/features/filter-todos/ui/TodoFilter/TodoFilter.tsx b/src/features/filter-todos/ui/TodoFilter/TodoFilter.tsx index eecd690..d9e62a6 100644 --- a/src/features/filter-todos/ui/TodoFilter/TodoFilter.tsx +++ b/src/features/filter-todos/ui/TodoFilter/TodoFilter.tsx @@ -1,6 +1,3 @@ -import { useAppDispatch, useAppSelector } from '@/app/hooks'; -import { filterChanged } from '@/features/filter-todos/model/filterSlice'; -import { selectFilterStatus } from '@/features/filter-todos/model/selectors'; import type { FilterStatus } from '@/features/filter-todos/model/types'; const OPTIONS: { value: FilterStatus; label: string }[] = [ @@ -9,20 +6,22 @@ const OPTIONS: { value: FilterStatus; label: string }[] = [ { value: 'completed', label: 'Completed' }, ]; -export const TodoFilter = () => { - const filterStatus = useAppSelector(selectFilterStatus); - const dispatch = useAppDispatch(); - const setFilter = (status: FilterStatus) => dispatch(filterChanged(status)); +interface TodoFilterProps { + status: FilterStatus; + onChange: (status: FilterStatus) => void; +} + +export const TodoFilter = ({ status, onChange }: TodoFilterProps) => { return (
{OPTIONS.map((option) => { - const isActive = filterStatus === option.value; + const isActive = status === option.value; return (