Тепер, коли ви знаєте всі інструменти Redux Toolkit, давайте поговоримо про те, як організувати код, щоб проєкт залишався підтримуваним навіть при зростанні.
1. Redux Toolkit завжди
2. Feature-based структура
3. Що не треба зберігати
4. Нормалізація колекцій
src/
├── actions/
│ ├── userActions.js
│ ├── postActions.js
│ └── commentActions.js
├── reducers/
│ ├── userReducer.js
│ ├── postReducer.js
│ └── commentReducer.js
├── constants/
│ └── actionTypes.js
├── components/
│ ├── UserList.jsx
│ └── PostList.jsx
└── store.js
Проблеми:
src/
├── features/
│ ├── auth/
│ │ ├── authSlice.js
│ │ ├── authApi.js (RTK Query)
│ │ ├── LoginForm.jsx
│ │ ├── useAuth.js (custom hook)
│ │ └── index.js (exports)
│ ├── posts/
│ │ ├── postsSlice.js
│ │ ├── postsApi.js
│ │ ├── PostsList.jsx
│ │ ├── PostDetail.jsx
│ │ ├── CreatePost.jsx
│ │ └── index.js
│ └── comments/
│ ├── commentsSlice.js
│ ├── commentsApi.js
│ ├── CommentsList.jsx
│ └── index.js
├── app/
│ ├── store.js
│ ├── rootReducer.js
│ └── App.jsx
└── shared/
├── components/
├── hooks/
└── utils/
Переваги:
// ❌ НЕ Redux
const [isModalOpen, setIsModalOpen] = useState(false)
// ❌ НЕ Redux, використовуйте React Hook Form
const { register, handleSubmit } = useForm()
// ❌ НЕ Redux
const [scrollY, setScrollY] = useState(0)
// ❌ Якщо лише один компонент використовує — local state
const [count, setCount] = useState(0)
// ❌ НІКОЛИ
state.callback = () => {}
state.fetchPromise = fetch('/api')
state.createdAt = new Date() // Зберігайте як ISO string!
{
// Auth
auth: {
user: { id: 1, name: 'John' },
token: 'abc123',
isAuthenticated: true,
},
// Entities (normalized)
posts: {
ids: [1, 2, 3],
entities: {
1: { id: 1, title: '...', authorId: 5 },
2: { id: 2, title: '...', authorId: 5 },
},
},
// UI preferences
ui: {
theme: 'dark',
sidebarOpen: true,
language: 'uk',
},
// RTK Query cache
api: {
queries: { /* auto-managed */ },
mutations: { /* auto-managed */ },
},
}
// ❌ Погано: nested entities
{
users: [
{
id: 1,
posts: [ // ❌ Дублювання!
{ id: 10, comments: [/* ... */] }
]
}
]
}
// ✅ Добре: normalized
{
users: { ids: [1], entities: { 1: {...} } },
posts: { ids: [10], entities: { 10: { userId: 1 } } },
comments: { ids: [...], entities: {...} }
}
import { createSlice, createSelector, createEntityAdapter } from '@reduxjs/toolkit'
import { fetchTodos, createTodo } from './todosThunks'
// 1. Entity Adapter (якщо є колекція)
const todosAdapter = createEntityAdapter()
// 2. Initial State
const initialState = todosAdapter.getInitialState({
loading: 'idle',
error: null,
filter: 'all',
})
// 3. Slice
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// Синхронні actions
filterChanged(state, action) {
state.filter = action.payload
},
todoToggled(state, action) {
const todo = state.entities[action.payload]
if (todo) {
todo.completed = !todo.completed
}
},
},
extraReducers: (builder) => {
// Async thunks
builder.addCase(fetchTodos.fulfilled, (state, action) => {
todosAdapter.setAll(state, action.payload)
state.loading = 'succeeded'
})
},
})
// 4. Selectors
const selectTodosState = (state) => state.todos
export const todosSelectors = todosAdapter.getSelectors(selectTodosState)
export const selectFilteredTodos = createSelector(
[todosSelectors.selectAll, (state) => state.todos.filter],
(todos, filter) => {
if (filter === 'completed') return todos.filter((t) => t.completed)
if (filter === 'active') return todos.filter((t) => !t.completed)
return todos
},
)
// 5. Exports
export const { filterChanged, todoToggled } = todosSlice.actions
export default todosSlice.reducer
// ❌ Створює новий масив кожного разу
const selectCompletedTodos = (state) => state.todos.filter((t) => t.completed)
// ✅ Memoized
const selectCompletedTodos = createSelector([(state) => state.todos], (todos) => todos.filter((t) => t.completed))
// ❌ O(n) lookup
const todo = state.todos.find((t) => t.id === 5)
// ✅ O(1) lookup з Entity Adapter
const todo = state.todos.entities[5]
// ❌ Погано
reducers: {
updateDeep(state) {
return JSON.parse(JSON.stringify(state)); // Повільно!
}
}
// ✅ Добре: Immer робить це ефективно
reducers: {
updateDeep(state) {
state.deeply.nested.value = 'new'; // Immer оптимізує
}
}
import todosReducer, { todoAdded } from './todosSlice'
test('todoAdded додає todo', () => {
const initialState = { items: [] }
const newState = todosReducer(
initialState,
todoAdded({
id: 1,
text: 'Test',
completed: false,
}),
)
expect(newState.items).toHaveLength(1)
expect(newState.items[0].text).toBe('Test')
})
import { selectCompletedTodos } from './todosSlice'
test('selectCompletedTodos filters', () => {
const state = {
todos: [
{ id: 1, completed: true },
{ id: 2, completed: false },
],
}
const result = selectCompletedTodos(state)
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
import { fetchTodos } from './todosThunks'
import { configureStore } from '@reduxjs/toolkit'
test('fetchTodos завантажує todos', async () => {
const store = configureStore({ reducer: { todos: todosReducer } })
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve([{ id: 1, text: 'Test' }]),
}),
)
await store.dispatch(fetchTodos())
const state = store.getState()
expect(state.todos.items).toHaveLength(1)
})
import { configureStore } from '@reduxjs/toolkit'
import todosReducer from './features/todos/todosSlice'
export const store = configureStore({
reducer: {
todos: todosReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
interface TodosState {
items: Todo[]
loading: 'idle' | 'pending' | 'succeeded' | 'failed'
error: string | null
}
const initialState: TodosState = {
items: [],
loading: 'idle',
error: null,
}
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded: (state, action: PayloadAction<Todo>) => {
state.items.push(action.payload)
},
},
})
// ❌ НІКОЛИ
const handleClick = () => {
const state = store.getState()
state.todos.push(newTodo) // ЗАБОРОНЕНО!
}
// ✅ Завжди dispatch
const handleClick = () => {
dispatch(todoAdded(newTodo))
}
// ❌ Бізнес-логіка в компоненті
function Cart() {
const items = useSelector((state) => state.cart.items)
const total = items.reduce((sum, item) => sum + item.price * item.qty, 0)
// ...
}
// ✅ Логіка в селекторі
const selectCartTotal = createSelector([(state) => state.cart.items], (items) =>
items.reduce((sum, item) => sum + item.price * item.qty, 0),
)
// ❌ Один slice для всього
const appSlice = createSlice({
name: 'app',
initialState: {
auth: {},
todos: [],
posts: [],
comments: [],
// ... 10 інших features
},
});
// ✅ Окремі slices
const authSlice = createSlice({ name: 'auth', ... });
const todosSlice = createSlice({ name: 'todos', ... });
const postsSlice = createSlice({ name: 'posts', ... });
Redux DevTools — найкращий інструмент для debugging. Використовуйте:
npm install @reduxjs/toolkit
// Було
const store = createStore(rootReducer, applyMiddleware(thunk))
// Стало
const store = configureStore({ reducer: rootReducer })
Redux DevTools
Immer
Reselect
Redux Persist
Золоті правила Redux:
useState — найкраще рішення!Вітаю! 🎉 Ви освоїли Redux Toolkit та готові будувати масштабовані додатки!
RTK Query: Архітектура Серверного Кешу
RTK Query — це не просто "fetcher" даних. Це повноцінний Server State Manager, який інтегрований у Redux Toolkit. Він перевертає уявлення про те, як ми працюємо з даними на клієнті, зміщуючи фокус з "ручного управління loading/error/data" на "декларативне описування залежностей".
Проєкт: Kanban Board (Trello Clone)
Щоб закріпити знання Redux Toolkit та RTK Query, ми створимо повноцінний додаток — клон Trello. Це не просто "Todo list". Це складний додаток зі складною логікою переміщення карток, оптимістичними оновленнями та синхронізацією з сервером.