Архітектура та Best Practices
Архітектура та Best Practices
Тепер, коли ви знаєте всі інструменти 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
Проблеми:
- Логіка однієї feature розкидана по папках
- Складно видалити feature
- Важко знайти пов'язаний код
✅ Хороша структура (за features)
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/
Переваги:
- Вся логіка feature в одному місці
- Легко видалити/перенести feature
- Code splitting природний
- Команди можуть працювати паралельно
Що НЕ зберігати в Redux
- UI State (modals, tooltips, accordion expanded)
// ❌ НЕ Redux const [isModalOpen, setIsModalOpen] = useState(false) - Form State (поточні значення inputs)
// ❌ НЕ Redux, використовуйте React Hook Form const { register, handleSubmit } = useForm() - Тимчасові дані (hover states, scroll position)
// ❌ НЕ Redux const [scrollY, setScrollY] = useState(0) - Дані, що не діляться між компонентами
// ❌ Якщо лише один компонент використовує — local state const [count, setCount] = useState(0) - Non-serializable (Functions, Promises, Date objects)
// ❌ НІКОЛИ state.callback = () => {} state.fetchPromise = fetch('/api') state.createdAt = new Date() // Зберігайте як ISO string!
Коли використовувати Redux
✅ Використовуйте Redux для:
- Auth state (user, token, permissions)
- App-wide preferences (theme, language, sidebar state)
- Server cache (users, posts, products)
- Entity collections (списки з CRUD операціями)
- Complex shared state (shopping cart, multi-step wizards)
- Real-time data (notifications, chat messages)
❌ НЕ використовуйте Redux для:
- Form state → React Hook Form, Formik
- Component UI state → useState
- Routing state → React Router
- Server state (якщо простий CRUD) → RTK Query, React Query
- Temporary UI → component state
State Shape Design
Добра структура state
{
// 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 */ },
},
}
Anti-patterns
// ❌ Погано: 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: {...} }
}
Slice Organization
Template слайсу
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
Performance Best Practices
1. Memoized Selectors
// ❌ Створює новий масив кожного разу
const selectCompletedTodos = (state) => state.todos.filter((t) => t.completed)
// ✅ Memoized
const selectCompletedTodos = createSelector([(state) => state.todos], (todos) => todos.filter((t) => t.completed))
2. Нормалізація даних
// ❌ O(n) lookup
const todo = state.todos.find((t) => t.id === 5)
// ✅ O(1) lookup з Entity Adapter
const todo = state.todos.entities[5]
3. Уникайте глибоких копій
// ❌ Погано
reducers: {
updateDeep(state) {
return JSON.parse(JSON.stringify(state)); // Повільно!
}
}
// ✅ Добре: Immer робить це ефективно
reducers: {
updateDeep(state) {
state.deeply.nested.value = 'new'; // Immer оптимізує
}
}
Testing Strategies
Test Slices
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')
})
Test Selectors
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)
})
Test Thunks
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)
})
TypeScript Best Practices
Типізація Store
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
Typed Hooks
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
Typed Slice
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)
},
},
})
Common Mistakes
// ❌ НІКОЛИ
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. Використовуйте:
- Time-travel debugging
- Action replay
- State diff
- Trace для розуміння причин змін
Migration Strategy
З класичного Redux на RTK
- Встановіть RTK
npm install @reduxjs/toolkit - Замініть createStore на configureStore
// Було const store = createStore(rootReducer, applyMiddleware(thunk)) // Стало const store = configureStore({ reducer: rootReducer }) - Перепишіть slices по одному
- Почніть з найпростішого
- Об'єднайте constants + actions + reducer в createSlice
- Тестуйте після кожного slice
- Додайте Entity Adapters де потрібно
- Розгляньте RTK Query для API
Рекомендовані інструменти
Redux DevTools
Immer
Reselect
Redux Persist
Checklist для ревью коду
- Використовується Redux Toolkit (не класичний Redux)
- Feature-based структура папок
- Entity Adapter для колекцій
- createSelector для складних обчислень
- Немає non-serializable даних в state
- Немає form/UI state в Redux (якщо не треба ділитися)
- RTK Query для CRUD операцій
- TypeScript типізація (якщо TS проєкт)
- Tests для reducers та селекторів
- DevTools увімкнені в development
Висновок
Золоті правила Redux:
- ✅ Redux Toolkit завжди
- ✅ Feature-based структура
- ✅ Нормалізуйте колекції
- ✅ Мемоізація селекторів
- ✅ RTK Query для API
- ❌ НЕ зберігайте form/UI state
- ❌ НЕ мутуйте state поза Immer
- ❌ НЕ ігноруйте DevTools
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". Це складний додаток зі складною логікою переміщення карток, оптимістичними оновленнями та синхронізацією з сервером.