Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4a861d4
docs(wiki): add state management overview page
Demonkratiy Jun 28, 2026
ef2d944
feat(redux): set up RTK store, typed hooks and todo slice
Demonkratiy Jun 28, 2026
329ae8b
docs(wiki): add Redux setup page
Demonkratiy Jun 28, 2026
6551288
feat(redux): move todos state into the store
Demonkratiy Jun 28, 2026
a2cca53
docs(wiki): add static store-overview note and update next steps
Demonkratiy Jun 28, 2026
4b208d5
feat(redux): add filter slice
Demonkratiy Jun 29, 2026
6a87413
feat(redux): move filter state into the store
Demonkratiy Jun 29, 2026
20306d7
docs(wiki): add Redux concepts page
Demonkratiy Jun 29, 2026
e73ce90
feat(redux): add todos and filter selectors
Demonkratiy Jun 30, 2026
da71825
refactor(redux): derive visible todos via memoized selector
Demonkratiy Jun 30, 2026
bfcb1ed
docs(wiki): explain reducer-local vs selector-global state
Demonkratiy Jun 30, 2026
d26e4d1
chore(redux): add store factory and Storybook Provider decorator
Demonkratiy Jul 4, 2026
36cb787
refactor(redux): connect feature and widget components to the store
Demonkratiy Jul 4, 2026
6a7162c
refactor(redux): reduce HomePage to pure composition
Demonkratiy Jul 4, 2026
7801a02
docs(wiki): selectors, connected-vs-presentational, Storybook+Redux
Demonkratiy Jul 4, 2026
4665194
docs(wiki): add state managers comparison (Redux vs Zustand vs MobX)
Demonkratiy Jul 4, 2026
9e37f79
refactor(widgets): consolidate todo logic into TodoWidget facade hook
Demonkratiy Aug 19, 2026
bba8e98
docs(wiki): facade hook pattern and React hooks fundamentals
Demonkratiy Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Provider store={store}>
<Story />
</Provider>
);
},
],
parameters: {
controls: {
matchers: {
Expand Down
10 changes: 10 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}' } }],
},
],
},
],
Expand Down
100 changes: 96 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions src/app/hooks.ts
Original file line number Diff line number Diff line change
@@ -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<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
6 changes: 5 additions & 1 deletion src/app/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<StrictMode>
<HomePage />
<Provider store={store}>
<HomePage />
</Provider>
</StrictMode>,
);
22 changes: 22 additions & 0 deletions src/app/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { todoReducer } from '@/entities/todo';
import { filterReducer } from '@/features/filter-todos';
import { combineReducers, configureStore } from '@reduxjs/toolkit';

const rootReducer = combineReducers({
todos: todoReducer,
filter: filterReducer,
});

// 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<RootState>) => {
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<typeof rootReducer>;
export type AppStore = ReturnType<typeof setupStore>;
export type AppDispatch = AppStore['dispatch'];
4 changes: 3 additions & 1 deletion src/entities/todo/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +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 { TodoItem } from './ui/TodoItem';
4 changes: 4 additions & 0 deletions src/entities/todo/model/selectors.ts
Original file line number Diff line number Diff line change
@@ -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;
42 changes: 42 additions & 0 deletions src/entities/todo/model/todoSlice.ts
Original file line number Diff line number Diff line change
@@ -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<Todo>) {
state.items.push(action.payload);
},
prepare(text: string) {
return {
payload: { id: crypto.randomUUID(), text, completed: false } satisfies Todo,
};
},
},
todoToggled(state, action: PayloadAction<string>) {
const todo = state.items.find((item) => item.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
todoDeleted(state, action: PayloadAction<string>) {
state.items = state.items.filter((item) => item.id !== action.payload);
},
},
});

export const { todoAdded, todoToggled, todoDeleted } = todoSlice.actions;
export const todoReducer = todoSlice.reducer;
41 changes: 10 additions & 31 deletions src/features/add-todo/ui/AddTodoForm/AddTodoForm.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn, userEvent, within } from 'storybook/test';
import { expect, fn, userEvent, within } from 'storybook/test';
import { AddTodoForm } from './AddTodoForm';

const meta: Meta<typeof AddTodoForm> = {
Expand All @@ -10,42 +9,22 @@ const meta: Meta<typeof AddTodoForm> = {
layout: 'padded',
},
tags: ['autodocs'],
args: {
onAdd: fn(),
},
args: { onAdd: fn() },
};
export default meta;

type Story = StoryObj<typeof AddTodoForm>;

export const Interactive: Story = {
render: (args) => {
const [todos, setTodos] = useState<string[]>([]);

const handleAdd = (text: string) => {
args.onAdd(text); // вызовем из args — попадёт в Actions panel
setTodos((prev) => [...prev, text]);
};

return (
<div className='flex flex-col gap-3'>
<AddTodoForm onAdd={handleAdd} />
<ul>
{todos.map((t, i) => (
<li key={i}>• {t}</li>
))}
</ul>
</div>
);
},
};

export const Empty: Story = {};
// Presentational again — onAdd is a prop, the widget owns the dispatch.
export const Default: Story = {};

export const Filled: Story = {
play: async ({ canvasElement }) => {
export const AddsTodo: Story = {
play: async ({ args, 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(args.onAdd).toHaveBeenCalledWith('Buy milk');
},
};
1 change: 1 addition & 0 deletions src/features/add-todo/ui/AddTodoForm/AddTodoForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface AddTodoFormProps {
export const AddTodoForm = ({ onAdd }: AddTodoFormProps) => {
const [text, setText] = useState('');
const trimmedText = text.trim();

const handleSubmit = (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (trimmedText === '') return;
Expand Down
Loading