← React/Redux: паттерны и лучшие практики#305 из 383← ПредыдущийСледующий →+30 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

Redux: паттерны и лучшие практики

Структура проекта

Feature-based структура (рекомендуется)

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 }
    }
  }
}

createEntityAdapter

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)

Селекторы с Reselect

Мемоизированные селекторы для производительности:

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)
        }
      )
  }
})

Middleware для логирования

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)
})

Persist состояния

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 ? ' ✓' : '')))

Redux: паттерны и лучшие практики

Структура проекта

Feature-based структура (рекомендуется)

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 }
    }
  }
}

createEntityAdapter

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)

Селекторы с Reselect

Мемоизированные селекторы для производительности:

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)
        }
      )
  }
})

Middleware для логирования

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)
})

Persist состояния

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.

Загружаем среду выполнения...
Загружаем AI-помощника...