src/
├── app/
│ ├── store.js
│ └── hooks.js # Типизированные useDispatch/useSelector
├── features/
│ ├── auth/
│ │ ├── authSlice.js
│ │ ├── authApi.js # RTK Query
│ │ ├── Login.jsx
│ │ └── index.js
│ ├── posts/
│ │ ├── postsSlice.js
│ │ ├── postsApi.js
│ │ ├── PostList.jsx
│ │ ├── PostItem.jsx
│ │ └── index.js
│ └── users/
│ └── ...
└── common/
└── components/// app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()Вместо вложенных массивов используй плоскую структуру:
// ❌ Плохо: вложенные данные
{
posts: [
{
id: 1,
title: 'Post 1',
author: { id: 1, name: 'Алексей' },
comments: [
{ id: 1, text: 'Отлично!', author: { id: 2, name: 'Мария' } }
]
}
]
}
// ✅ Хорошо: нормализованные данные
{
posts: {
ids: [1, 2, 3],
entities: {
1: { id: 1, title: 'Post 1', authorId: 1, commentIds: [1, 2] },
2: { id: 2, title: 'Post 2', authorId: 1, commentIds: [] }
}
},
users: {
ids: [1, 2],
entities: {
1: { id: 1, name: 'Алексей' },
2: { id: 2, name: 'Мария' }
}
},
comments: {
ids: [1, 2],
entities: {
1: { id: 1, text: 'Отлично!', authorId: 2, postId: 1 }
}
}
}RTK предоставляет утилиту для нормализованных данных:
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit'
const postsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.date.localeCompare(a.date)
})
const postsSlice = createSlice({
name: 'posts',
initialState: postsAdapter.getInitialState({
status: 'idle'
}),
reducers: {
postAdded: postsAdapter.addOne,
postUpdated: postsAdapter.updateOne,
postRemoved: postsAdapter.removeOne,
postsReceived: postsAdapter.setAll
}
})
// Селекторы — автогенерированные
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds
} = postsAdapter.getSelectors(state => state.posts)Мемоизированные селекторы для производительности:
import { createSelector } from '@reduxjs/toolkit'
// Базовые селекторы
const selectPosts = state => state.posts.items
const selectFilter = state => state.posts.filter
// Мемоизированный селектор
export const selectFilteredPosts = createSelector(
[selectPosts, selectFilter],
(posts, filter) => {
console.log('Пересчёт selectFilteredPosts') // Только при изменении inputs
switch (filter) {
case 'completed':
return posts.filter(p => p.completed)
case 'active':
return posts.filter(p => !p.completed)
default:
return posts
}
}
)
// Селектор с параметром
export const selectPostsByUser = createSelector(
[selectPosts, (state, userId) => userId],
(posts, userId) => posts.filter(p => p.userId === userId)
)const postsSlice = createSlice({
name: 'posts',
initialState: {
items: [],
status: 'idle',
error: null
},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed'
// action.payload — если использовали rejectWithValue
// action.error — стандартная ошибка
state.error = action.payload ?? action.error.message
})
// Глобальный обработчик для всех rejected
.addMatcher(
(action) => action.type.endsWith('/rejected'),
(state, action) => {
console.error('Global error handler:', action.error)
}
)
}
})const loggerMiddleware = (store) => (next) => (action) => {
console.group(action.type)
console.log('dispatching', action)
const result = next(action)
console.log('next state', store.getState())
console.groupEnd()
return result
}
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(loggerMiddleware)
})import { persistStore, persistReducer } from 'redux-persist'
import storage from 'redux-persist/lib/storage'
const persistConfig = {
key: 'root',
storage,
whitelist: ['auth', 'settings'] // Только эти слайсы сохраняются
}
const persistedReducer = persistReducer(persistConfig, rootReducer)
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE']
}
})
})1. Три принципа Redux — Single source of truth, State is read-only, Pure reducers
2. Redux Toolkit — createSlice, configureStore, createAsyncThunk
3. RTK Query — автоматический data fetching с кэшированием
4. Selectors — createSelector для мемоизации
5. Нормализация — createEntityAdapter
6. Middleware — как работает, зачем нужен
7. DevTools — отладка, time-travel
Полный пример: нормализация + селекторы + Entity Adapter паттерн
// === Entity Adapter паттерн ===
// Создаём адаптер для нормализованных данных
function createEntityAdapter(options = {}) {
const { selectId = (entity) => entity.id, sortComparer } = options
return {
getInitialState: (additionalState = {}) => ({
ids: [],
entities: {},
...additionalState
}),
addOne: (state, action) => {
const entity = action.payload
const id = selectId(entity)
if (!state.entities[id]) {
state.ids.push(id)
state.entities[id] = entity
if (sortComparer) {
state.ids.sort((a, b) => sortComparer(state.entities[a], state.entities[b]))
}
}
},
updateOne: (state, action) => {
const { id, changes } = action.payload
if (state.entities[id]) {
state.entities[id] = { ...state.entities[id], ...changes }
}
},
removeOne: (state, action) => {
const id = action.payload
delete state.entities[id]
state.ids = state.ids.filter(i => i !== id)
},
setAll: (state, action) => {
const entities = action.payload
state.entities = {}
state.ids = entities.map(entity => {
const id = selectId(entity)
state.entities[id] = entity
return id
})
if (sortComparer) {
state.ids.sort((a, b) => sortComparer(state.entities[a], state.entities[b]))
}
},
getSelectors: (selectState) => ({
selectAll: (state) => {
const slice = selectState(state)
return slice.ids.map(id => slice.entities[id])
},
selectById: (state, id) => selectState(state).entities[id],
selectIds: (state) => selectState(state).ids,
selectTotal: (state) => selectState(state).ids.length
})
}
}
// === Создаём адаптер для задач ===
const todosAdapter = createEntityAdapter({
sortComparer: (a, b) => a.createdAt.localeCompare(b.createdAt)
})
// Начальное состояние с дополнительными полями
let state = todosAdapter.getInitialState({
filter: 'all',
status: 'idle'
})
// Селекторы
const selectTodosState = (state) => state
const {
selectAll: selectAllTodos,
selectById: selectTodoById,
selectIds: selectTodoIds,
selectTotal: selectTotalTodos
} = todosAdapter.getSelectors(selectTodosState)
// === Мемоизированный селектор ===
function createSelector(inputSelectors, resultFunc) {
let lastInputs = null
let lastResult = null
return (state, ...args) => {
const inputs = inputSelectors.map(selector =>
typeof selector === 'function' ? selector(state, ...args) : selector
)
const inputsChanged = !lastInputs || inputs.some((input, i) => input !== lastInputs[i])
if (inputsChanged) {
console.log('📊 Selector: пересчёт результата')
lastResult = resultFunc(...inputs)
lastInputs = inputs
} else {
console.log('📊 Selector: из кэша')
}
return lastResult
}
}
const selectFilteredTodos = createSelector(
[selectAllTodos, (state) => state.filter],
(todos, filter) => {
switch (filter) {
case 'active': return todos.filter(t => !t.completed)
case 'completed': return todos.filter(t => t.completed)
default: return todos
}
}
)
// === Демонстрация ===
console.log('=== Entity Adapter + Selectors ===\n')
// Добавляем задачи
const todos = [
{ id: 1, text: 'Изучить Redux', completed: true, createdAt: '2024-01-01' },
{ id: 2, text: 'Практика RTK', completed: false, createdAt: '2024-01-02' },
{ id: 3, text: 'Освоить паттерны', completed: false, createdAt: '2024-01-03' },
]
todosAdapter.setAll(state, { payload: todos })
console.log('После setAll:', state)
// Используем селекторы
console.log('\nВсе задачи:', selectAllTodos(state).map(t => t.text))
console.log('Задача #2:', selectTodoById(state, 2))
console.log('Всего задач:', selectTotalTodos(state))
// Мемоизированный селектор
console.log('\n--- Мемоизация ---')
state.filter = 'active'
console.log('Активные:', selectFilteredTodos(state).map(t => t.text))
console.log('Повторный вызов:')
selectFilteredTodos(state) // Из кэша
// Обновление задачи
console.log('\n--- Обновление ---')
todosAdapter.updateOne(state, { payload: { id: 2, changes: { completed: true } } })
console.log('После toggle #2:', selectAllTodos(state).map(t => t.text + (t.completed ? ' ✓' : '')))src/
├── app/
│ ├── store.js
│ └── hooks.js # Типизированные useDispatch/useSelector
├── features/
│ ├── auth/
│ │ ├── authSlice.js
│ │ ├── authApi.js # RTK Query
│ │ ├── Login.jsx
│ │ └── index.js
│ ├── posts/
│ │ ├── postsSlice.js
│ │ ├── postsApi.js
│ │ ├── PostList.jsx
│ │ ├── PostItem.jsx
│ │ └── index.js
│ └── users/
│ └── ...
└── common/
└── components/// app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()Вместо вложенных массивов используй плоскую структуру:
// ❌ Плохо: вложенные данные
{
posts: [
{
id: 1,
title: 'Post 1',
author: { id: 1, name: 'Алексей' },
comments: [
{ id: 1, text: 'Отлично!', author: { id: 2, name: 'Мария' } }
]
}
]
}
// ✅ Хорошо: нормализованные данные
{
posts: {
ids: [1, 2, 3],
entities: {
1: { id: 1, title: 'Post 1', authorId: 1, commentIds: [1, 2] },
2: { id: 2, title: 'Post 2', authorId: 1, commentIds: [] }
}
},
users: {
ids: [1, 2],
entities: {
1: { id: 1, name: 'Алексей' },
2: { id: 2, name: 'Мария' }
}
},
comments: {
ids: [1, 2],
entities: {
1: { id: 1, text: 'Отлично!', authorId: 2, postId: 1 }
}
}
}RTK предоставляет утилиту для нормализованных данных:
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit'
const postsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.date.localeCompare(a.date)
})
const postsSlice = createSlice({
name: 'posts',
initialState: postsAdapter.getInitialState({
status: 'idle'
}),
reducers: {
postAdded: postsAdapter.addOne,
postUpdated: postsAdapter.updateOne,
postRemoved: postsAdapter.removeOne,
postsReceived: postsAdapter.setAll
}
})
// Селекторы — автогенерированные
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds
} = postsAdapter.getSelectors(state => state.posts)Мемоизированные селекторы для производительности:
import { createSelector } from '@reduxjs/toolkit'
// Базовые селекторы
const selectPosts = state => state.posts.items
const selectFilter = state => state.posts.filter
// Мемоизированный селектор
export const selectFilteredPosts = createSelector(
[selectPosts, selectFilter],
(posts, filter) => {
console.log('Пересчёт selectFilteredPosts') // Только при изменении inputs
switch (filter) {
case 'completed':
return posts.filter(p => p.completed)
case 'active':
return posts.filter(p => !p.completed)
default:
return posts
}
}
)
// Селектор с параметром
export const selectPostsByUser = createSelector(
[selectPosts, (state, userId) => userId],
(posts, userId) => posts.filter(p => p.userId === userId)
)const postsSlice = createSlice({
name: 'posts',
initialState: {
items: [],
status: 'idle',
error: null
},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed'
// action.payload — если использовали rejectWithValue
// action.error — стандартная ошибка
state.error = action.payload ?? action.error.message
})
// Глобальный обработчик для всех rejected
.addMatcher(
(action) => action.type.endsWith('/rejected'),
(state, action) => {
console.error('Global error handler:', action.error)
}
)
}
})const loggerMiddleware = (store) => (next) => (action) => {
console.group(action.type)
console.log('dispatching', action)
const result = next(action)
console.log('next state', store.getState())
console.groupEnd()
return result
}
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(loggerMiddleware)
})import { persistStore, persistReducer } from 'redux-persist'
import storage from 'redux-persist/lib/storage'
const persistConfig = {
key: 'root',
storage,
whitelist: ['auth', 'settings'] // Только эти слайсы сохраняются
}
const persistedReducer = persistReducer(persistConfig, rootReducer)
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE']
}
})
})1. Три принципа Redux — Single source of truth, State is read-only, Pure reducers
2. Redux Toolkit — createSlice, configureStore, createAsyncThunk
3. RTK Query — автоматический data fetching с кэшированием
4. Selectors — createSelector для мемоизации
5. Нормализация — createEntityAdapter
6. Middleware — как работает, зачем нужен
7. DevTools — отладка, time-travel
Полный пример: нормализация + селекторы + Entity Adapter паттерн
// === Entity Adapter паттерн ===
// Создаём адаптер для нормализованных данных
function createEntityAdapter(options = {}) {
const { selectId = (entity) => entity.id, sortComparer } = options
return {
getInitialState: (additionalState = {}) => ({
ids: [],
entities: {},
...additionalState
}),
addOne: (state, action) => {
const entity = action.payload
const id = selectId(entity)
if (!state.entities[id]) {
state.ids.push(id)
state.entities[id] = entity
if (sortComparer) {
state.ids.sort((a, b) => sortComparer(state.entities[a], state.entities[b]))
}
}
},
updateOne: (state, action) => {
const { id, changes } = action.payload
if (state.entities[id]) {
state.entities[id] = { ...state.entities[id], ...changes }
}
},
removeOne: (state, action) => {
const id = action.payload
delete state.entities[id]
state.ids = state.ids.filter(i => i !== id)
},
setAll: (state, action) => {
const entities = action.payload
state.entities = {}
state.ids = entities.map(entity => {
const id = selectId(entity)
state.entities[id] = entity
return id
})
if (sortComparer) {
state.ids.sort((a, b) => sortComparer(state.entities[a], state.entities[b]))
}
},
getSelectors: (selectState) => ({
selectAll: (state) => {
const slice = selectState(state)
return slice.ids.map(id => slice.entities[id])
},
selectById: (state, id) => selectState(state).entities[id],
selectIds: (state) => selectState(state).ids,
selectTotal: (state) => selectState(state).ids.length
})
}
}
// === Создаём адаптер для задач ===
const todosAdapter = createEntityAdapter({
sortComparer: (a, b) => a.createdAt.localeCompare(b.createdAt)
})
// Начальное состояние с дополнительными полями
let state = todosAdapter.getInitialState({
filter: 'all',
status: 'idle'
})
// Селекторы
const selectTodosState = (state) => state
const {
selectAll: selectAllTodos,
selectById: selectTodoById,
selectIds: selectTodoIds,
selectTotal: selectTotalTodos
} = todosAdapter.getSelectors(selectTodosState)
// === Мемоизированный селектор ===
function createSelector(inputSelectors, resultFunc) {
let lastInputs = null
let lastResult = null
return (state, ...args) => {
const inputs = inputSelectors.map(selector =>
typeof selector === 'function' ? selector(state, ...args) : selector
)
const inputsChanged = !lastInputs || inputs.some((input, i) => input !== lastInputs[i])
if (inputsChanged) {
console.log('📊 Selector: пересчёт результата')
lastResult = resultFunc(...inputs)
lastInputs = inputs
} else {
console.log('📊 Selector: из кэша')
}
return lastResult
}
}
const selectFilteredTodos = createSelector(
[selectAllTodos, (state) => state.filter],
(todos, filter) => {
switch (filter) {
case 'active': return todos.filter(t => !t.completed)
case 'completed': return todos.filter(t => t.completed)
default: return todos
}
}
)
// === Демонстрация ===
console.log('=== Entity Adapter + Selectors ===\n')
// Добавляем задачи
const todos = [
{ id: 1, text: 'Изучить Redux', completed: true, createdAt: '2024-01-01' },
{ id: 2, text: 'Практика RTK', completed: false, createdAt: '2024-01-02' },
{ id: 3, text: 'Освоить паттерны', completed: false, createdAt: '2024-01-03' },
]
todosAdapter.setAll(state, { payload: todos })
console.log('После setAll:', state)
// Используем селекторы
console.log('\nВсе задачи:', selectAllTodos(state).map(t => t.text))
console.log('Задача #2:', selectTodoById(state, 2))
console.log('Всего задач:', selectTotalTodos(state))
// Мемоизированный селектор
console.log('\n--- Мемоизация ---')
state.filter = 'active'
console.log('Активные:', selectFilteredTodos(state).map(t => t.text))
console.log('Повторный вызов:')
selectFilteredTodos(state) // Из кэша
// Обновление задачи
console.log('\n--- Обновление ---')
todosAdapter.updateOne(state, { payload: { id: 2, changes: { completed: true } } })
console.log('После toggle #2:', selectAllTodos(state).map(t => t.text + (t.completed ? ' ✓' : '')))Создай приложение заметок с нормализованным состоянием. Реализуй Entity Adapter для хранения заметок, добавь фильтрацию по категориям с мемоизированным селектором.
Для фильтрации: note.category === state.filter. useMemo пересчитает результат только когда изменятся allNotes или state.filter.