Перейти к основному содержимому

Redux Fundamentals, Part 7: Стандартные паттерны Redux

Неофициальный Бета-перевод

Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →

Что вы изучите
  • Стандартные паттерны, используемые в реальных Redux-приложениях, и причины их появления:
    • Создатели действий (action creators) для инкапсуляции объектов действий
    • Мемоизированные селекторы для улучшения производительности
    • Отслеживание статуса запросов через перечисления загрузки
    • Нормализация состояния для управления коллекциями элементов
    • Работа с промисами и thunk-функциями
Предварительные требования
  • Понимание тем из всех предыдущих разделов

В Части 6: Асинхронная логика и получение данных мы рассмотрели, как использовать Redux middleware для написания асинхронной логики, взаимодействующей со хранилищем. В частности, мы использовали Redux "thunk" middleware для создания функций, содержащих переиспользуемую асинхронную логику, без предварительного знания о том, с каким хранилищем Redux они будут взаимодействовать.

До сих пор мы рассматривали основы работы Redux. Однако реальные Redux-приложения используют дополнительные паттерны поверх этих основ.

Важно отметить, что ни один из этих паттернов не является обязательным для использования Redux! Но существуют веские причины для появления каждого из них, и вы встретите некоторые или все из них практически в каждой кодовой базе Redux.

В этом разделе мы переработаем код нашего todo-приложения для использования некоторых из этих паттернов и обсудим, почему они часто применяются в Redux-приложениях. Затем в Части 8 мы поговорим о "современном Redux", включая использование официального пакета Redux Toolkit для упрощения всей Redux-логики, написанной "вручную" в нашем приложении, и почему мы рекомендуем Redux Toolkit как стандартный подход для разработки Redux-приложений.

Неофициальный Бета-перевод

Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →

Внимание!

Обратите внимание, что в этом руководстве намеренно используются устаревшие шаблоны логики Redux, требующие больше кода, чем "современные Redux" шаблоны с Redux Toolkit, которые мы рекомендуем как правильный подход для создания приложений на Redux сегодня. Это сделано для объяснения принципов и концепций Redux. Данный материал не предназначен для использования в production-проектах.

Чтобы изучить "современный Redux" с Redux Toolkit, перейдите по ссылкам:

Создатели действий (Action Creators)

В нашем приложении мы писали объекты действий напрямую в коде, в местах их диспетчеризации:

dispatch({ type: 'todos/todoAdded', payload: trimmedText })

Однако на практике хорошо написанные Redux-приложения не создают эти объекты действий встроенными при диспетчеризации. Вместо этого используются функции-создатели действий ("action creators").

Создатель действия (action creator) — это функция, которая создает и возвращает объект действия. Обычно их используют, чтобы не писать объект действия вручную каждый раз:

const todoAdded = text => {
return {
type: 'todos/todoAdded',
payload: text
}
}

Мы используем их, вызывая создатель действия, а затем передавая полученный объект действия напрямую в dispatch:

store.dispatch(todoAdded('Buy milk'))

console.log(store.getState().todos)
// [ {id: 0, text: 'Buy milk', completed: false}]

Detailed Explanation: Why use Action Creators?

In our small example todo app, writing action objects by hand every time isn't too difficult. In fact, by switching to using action creators, we've added more work - now we have to write a function and the action object.

But, what if we needed to dispatch the same action from many parts of the application? Or what if there's some additional logic that we have to do every time we dispatch an action, like creating a unique ID? We'd end up having to copy-paste the additional setup logic every time we need to dispatch that action.

Action creators have two primary purposes:

  • They prepare and format the contents of action objects
  • They encapsulate any additional work needed whenever we create those actions

That way, we have a consistent approach for creating actions, whether or not there's any extra work that needs to be done. The same goes for thunks as well.

Использование создателей действий

Обновим наш файл среза задач (todos slice), чтобы использовать создателей действий для нескольких типов действий.

Начнём с двух основных действий, которые мы использовали до сих пор: загрузка списка задач с сервера и добавление новой задачи после её сохранения на сервере.

Сейчас todosSlice.js диспетчеризирует объект действия напрямую, например:

dispatch({ type: 'todos/todosLoaded', payload: response.todos })

Мы создадим функцию, которая генерирует и возвращает такой же объект действия, но принимает массив задач в качестве аргумента и помещает его в action.payload. Затем мы сможем диспетчеризировать действие, используя этого нового создателя внутри нашего fetchTodos thunk:

src/features/todos/todosSlice.js
export const todosLoaded = todos => {
return {
type: 'todos/todosLoaded',
payload: todos
}
}

export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

Мы можем сделать то же самое для действия "задача добавлена":

src/features/todos/todosSlice.js
export const todoAdded = todo => {
return {
type: 'todos/todoAdded',
payload: todo
}
}

export function saveNewTodo(text) {
return async function saveNewTodoThunk(dispatch, getState) {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch(todoAdded(response.todo))
}
}

Заодно сделаем аналогичное изменение для действия "изменён фильтр по цвету":

src/features/filters/filtersSlice.js
export const colorFilterChanged = (color, changeType) => {
return {
type: 'filters/colorFilterChanged',
payload: { color, changeType }
}
}

Поскольку это действие диспетчеризировалось из компонента <Footer>, нам нужно импортировать создателя действия colorFilterChanged туда и использовать его:

src/features/footer/Footer.js
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'

import { availableColors, capitalize } from '../filters/colors'
import { StatusFilters, colorFilterChanged } from '../filters/filtersSlice'

// omit child components

const Footer = () => {
const dispatch = useDispatch()

const todosRemaining = useSelector(state => {
const uncompletedTodos = state.todos.filter(todo => !todo.completed)
return uncompletedTodos.length
})

const { status, colors } = useSelector(state => state.filters)

const onMarkCompletedClicked = () => dispatch({ type: 'todos/allCompleted' })
const onClearCompletedClicked = () =>
dispatch({ type: 'todos/completedCleared' })

const onColorChange = (color, changeType) =>
dispatch(colorFilterChanged(color, changeType))

const onStatusChange = status =>
dispatch({ type: 'filters/statusFilterChanged', payload: status })

// omit rendering output
}

export default Footer

Обратите внимание, что создатель действия colorFilterChanged фактически принимает два разных аргумента, а затем объединяет их для формирования правильного поля action.payload.

Это не меняет работу приложения или поведение потока данных в Redux — мы всё ещё создаём объекты действий и диспетчеризируем их. Но вместо постоянного написания объектов действий напрямую в коде мы теперь используем создателей действий для подготовки этих объектов перед диспетчеризацией.

Мы также можем использовать создатели действий (action creators) с функциями thunk. Фактически, мы уже обернули thunk в создатель действия в предыдущем разделе. Мы специально обернули saveNewTodo в функцию "создателя действия thunk" (thunk action creator), чтобы иметь возможность передавать параметр text. Хотя fetchTodos не принимает параметров, мы также можем обернуть его в создатель действия:

src/features/todos/todosSlice.js
export function fetchTodos() {
return async function fetchTodosThunk(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}
}

Это означает, что нам нужно изменить место, где он диспатчится в index.js: вызвать внешнюю функцию создателя действия thunk и передать возвращенную внутреннюю функцию thunk в dispatch:

src/index.js
import store from './store'
import { fetchTodos } from './features/todos/todosSlice'

store.dispatch(fetchTodos())

До сих пор мы писали thunks, используя ключевое слово function, чтобы было понятнее, что они делают. Однако мы также можем писать их, используя синтаксис стрелочных функций. Использование неявного возврата может сократить код, хотя это также может сделать его немного сложнее для чтения, если вы не знакомы со стрелочными функциями:

src/features/todos/todosSlice.js
// Same thing as the above example!
export const fetchTodos = () => async dispatch => {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

Аналогично, мы могли бы сократить обычные создатели действий, если захотим:

src/features/todos/todosSlice.js
export const todoAdded = todo => ({ type: 'todos/todoAdded', payload: todo })

Вам решать, лучше ли использовать стрелочные функции таким образом или нет.

Информация

Подробнее о том, почему создатели действий полезны, см.:

Мемоизированные селекторы

Мы уже видели, что можем писать функции-"селекторы", которые принимают объект state из Redux в качестве аргумента и возвращают значение:

const selectTodos = state => state.todos

Что если нам нужно получить (derive) некоторые данные? Например, мы можем захотеть получить массив только из ID задач:

const selectTodoIds = state => state.todos.map(todo => todo.id)

Однако array.map() всегда возвращает новую ссылку на массив. Мы знаем, что хук React-Redux useSelector будет запускать функцию-селектор заново после каждого диспатченного действия, и если результат селектора изменится, это приведет к повторному рендеру компонента.

В этом примере вызов useSelector(selectTodoIds) будет всегда приводить к повторному рендеру компонента после каждого действия, потому что возвращается новая ссылка на массив!

В Части 5 мы видели, что мы можем передать shallowEqual в качестве аргумента в useSelector. Однако есть и другой вариант: мы можем использовать "мемоизированные" селекторы.

Мемоизация — это разновидность кэширования: сохранение результатов дорогостоящих вычислений и повторное их использование, если входные данные не изменились.

Мемоизированные функции-селекторы — это селекторы, которые сохраняют последний результат и, если вы вызываете их несколько раз с одними и теми же входными данными, возвращают то же значение результата. Если вы вызываете их с другими входными данными, они пересчитывают новое значение результата, кэшируют его и возвращают новый результат.

Мемоизация селекторов с помощью createSelector

Библиотека Reselect предоставляет API createSelector, которое генерирует мемоизированные функции-селекторы. createSelector принимает одну или несколько функций-"входных селекторов" в качестве аргументов, а также "выходной селектор", и возвращает новую функцию-селектор. Каждый раз при вызове селектора:

  • Все "входные селекторы" вызываются со всеми аргументами

  • Если возвращаемое значение любого из входных селекторов изменилось, "выходной селектор" запустится заново

  • Все результаты входных селекторов становятся аргументами для выходного селектора

  • Конечный результат выходного селектора кэшируется для следующего раза

Давайте создадим мемоизированную версию selectTodoIds и используем её в нашем <TodoList>.

Сначала нам нужно установить Reselect:

npm install reselect

Затем мы можем импортировать и использовать createSelector. Наша исходная функция selectTodoIds была определена в TodoList.js, но чаще селекторные функции пишутся в соответствующем файле слайса. Поэтому добавим это в слайс todos:

src/features/todos/todosSlice.js
import { createSelector } from 'reselect'

// omit reducer

// omit action creators

export const selectTodoIds = createSelector(
// First, pass one or more "input selector" functions:
state => state.todos,
// Then, an "output selector" that receives all the input results as arguments
// and returns a final result value
todos => todos.map(todo => todo.id)
)

Теперь используем её в <TodoList>:

src/features/todos/TodoList.js
import React from 'react'
import { useSelector, shallowEqual } from 'react-redux'

import { selectTodoIds } from './todosSlice'
import TodoListItem from './TodoListItem'

const TodoList = () => {
const todoIds = useSelector(selectTodoIds)

const renderedListItems = todoIds.map(todoId => {
return <TodoListItem key={todoId} id={todoId} />
})

return <ul className="todo-list">{renderedListItems}</ul>
}

Это работает немного иначе, чем функция сравнения shallowEqual. При каждом изменении массива state.todos мы создаём новый массив идентификаторов задач. Это включает любые иммутабельные обновления элементов, например переключение поля completed, так как для иммутабельного обновления необходимо создать новый массив.

Совет

Мемоизированные селекторы полезны только при вычислении производных значений из исходных данных. Если вы просто извлекаете и возвращаете существующее значение, селектор можно оставить обычной функцией.

Селекторы с несколькими аргументами

Наше приложение должно фильтровать задачи по статусу выполнения. Напишем мемоизированный селектор для получения отфильтрованного списка.

Нам нужен весь массив todos как один аргумент, а также текущее значение фильтра по статусу. Добавим отдельные "входные селекторы" для извлечения каждого значения и передадим результаты "выходному селектору".

src/features/todos/todosSlice.js
import { createSelector } from 'reselect'
import { StatusFilters } from '../filters/filtersSlice'

// omit other code

export const selectFilteredTodos = createSelector(
// First input selector: all todos
state => state.todos,
// Second input selector: current status filter
state => state.filters.status,
// Output selector: receives both values
(todos, status) => {
if (status === StatusFilters.All) {
return todos
}

const completedStatus = status === StatusFilters.Completed
// Return either active or completed todos based on filter
return todos.filter(todo => todo.completed === completedStatus)
}
)
Внимание!

Обратите внимание: теперь появилась зависимость импорта между двумя слайсами — todosSlice импортирует значение из filtersSlice. Это допустимо, но будьте осторожны. Если два слайса попытаются импортировать что-то друг у друга, может возникнуть "циклическая зависимость импорта", приводящая к сбоям. В таком случае вынесите общий код в отдельный файл и импортируйте оттуда.

Теперь используем этот "фильтрованный" селектор как вход для другого селектора, возвращающего идентификаторы задач:

src/features/todos/todosSlice.js
export const selectFilteredTodoIds = createSelector(
// Pass our other memoized selector as an input
selectFilteredTodos,
// And derive data in the output selector
filteredTodos => filteredTodos.map(todo => todo.id)
)

Если переключить <TodoList> на использование selectFilteredTodoIds, мы сможем отмечать задачи как выполненные:

Todo app - todos marked completed

и фильтровать список, показывая только выполненные задачи:

Todo app - todos marked completed

Расширим selectFilteredTodos, добавив фильтрацию по цвету:

src/features/todos/todosSlice.js
export const selectFilteredTodos = createSelector(
// First input selector: all todos
selectTodos,
// Second input selector: all filter values
state => state.filters,
// Output selector: receives both values
(todos, filters) => {
const { status, colors } = filters
const showAllCompletions = status === StatusFilters.All
if (showAllCompletions && colors.length === 0) {
return todos
}

const completedStatus = status === StatusFilters.Completed
// Return either active or completed todos based on filter
return todos.filter(todo => {
const statusMatches =
showAllCompletions || todo.completed === completedStatus
const colorMatches = colors.length === 0 || colors.includes(todo.color)
return statusMatches && colorMatches
})
}
)

Обратите внимание: инкапсулировав логику в селекторе, нам не пришлось менять компонент при изменении фильтрации. Теперь можно фильтровать одновременно по статусу и цвету:

Todo app - status and color filters

Наконец, в нескольких местах у нас есть обращения к state.todos. В дальнейшем мы изменим структуру этого состояния, поэтому создадим единый селектор selectTodos и будем использовать его везде. Перенесём также selectTodoById в todosSlice:

src/features/todos/todosSlice.js
export const selectTodos = state => state.todos

export const selectTodoById = (state, todoId) => {
return selectTodos(state).find(todo => todo.id === todoId)
}
Информация

Подробнее о селекторах и мемоизации с Reselect:

Статус асинхронных запросов

Мы используем асинхронный thunk для загрузки начального списка задач с сервера. Поскольку используется фейковый API, ответ приходит мгновенно. В реальном приложении запрос может выполняться дольше. В таких случаях принято показывать индикатор загрузки.

В Redux-приложениях это обычно реализуется так:

  • Определением статуса загрузки для отображения текущего состояния запроса

  • Отправка действия "начало запроса" перед выполнением API-вызова, которое обрабатывается изменением состояния загрузки

  • Повторное обновление состояния загрузки при завершении запроса для индикации окончания вызова

Слой UI затем показывает индикатор загрузки во время выполнения запроса и переключается на отображение реальных данных после его завершения.

Мы обновим наш срез задач (todos slice), чтобы отслеживать состояние загрузки, и отправим дополнительное действие 'todos/todosLoading' как часть thunk-функции fetchTodos.

Сейчас state нашего редюсера задач представляет собой только массив самих задач. Если мы хотим отслеживать состояние загрузки внутри среза задач, нам нужно реорганизовать состояние в объект, содержащий массив задач и значение состояния загрузки. Это также означает переписывание логики редюсера для обработки дополнительной вложенности:

src/features/todos/todosSlice.js
const initialState = {
status: 'idle',
entities: []
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
return {
...state,
entities: [...state.entities, action.payload]
}
}
case 'todos/todoToggled': {
return {
...state,
entities: state.entities.map(todo => {
if (todo.id !== action.payload) {
return todo
}

return {
...todo,
completed: !todo.completed
}
})
}
}
// omit other cases
default:
return state
}
}

// omit action creators

export const selectTodos = state => state.todos.entities

Здесь важно отметить несколько моментов:

  • Массив задач теперь вложен как state.entities в объекте состояния todosReducer. Термин "entities" (сущности) означает "уникальные объекты с ID", что точно описывает наши задачи.

  • Это означает, что массив вложен в полное состояние Redux как state.todos.entities

  • Теперь нам нужно выполнять дополнительные шаги в редюсере для корректных неизменяемых обновлений с учётом вложенности: объект state -> массив entities -> объект todo

  • Поскольку остальной код обращается к состоянию задач исключительно через селекторы, нам нужно обновить только селектор selectTodos — остальная часть UI продолжит работать ожидаемо, несмотря на значительное изменение структуры состояния.

Значения перечисления для состояния загрузки

Вы также заметите, что мы определили поле состояния загрузки как строковое перечисление (enum):

{
status: 'idle' // or: 'loading', 'succeeded', 'failed'
}

вместо булева флага isLoading.

Булево значение ограничивает нас двумя состояниями: "загружается" или "не загружается". В реальности запрос может находиться в многих состояниях, таких как:

  • Ещё не начат

  • В процессе выполнения

  • Успешно завершён

  • Завершился с ошибкой

  • Успешно завершён, но теперь требуется повторная загрузка

Логика приложения также может требовать переходов только между определёнными состояниями на основе конкретных действий, что сложнее реализовать с помощью булевых значений.

Поэтому мы рекомендуем хранить состояние загрузки как значение строкового перечисления, а не булевы флаги.

Информация

Подробнее о том, почему состояния загрузки должны быть перечислениями, см.:

Исходя из этого, мы добавим новое действие "загрузка", которое установит статус 'loading', и обновим действие "загружено", чтобы сбросить состояние в 'idle':

src/features/todos/todosSlice.js
const initialState = {
status: 'idle',
entities: []
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other cases
case 'todos/todosLoading': {
return {
...state,
status: 'loading'
}
}
case 'todos/todosLoaded': {
return {
...state,
status: 'idle',
entities: action.payload
}
}
default:
return state
}
}

// omit action creators

// Thunk function
export const fetchTodos = () => async dispatch => {
dispatch(todosLoading())
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

Однако прежде чем отображать это в UI, нам нужно модифицировать фейковый API сервера, добавив искусственную задержку вызовам. Откройте src/api/server.js и найдите закомментированную строку около 63 строки:

src/api/server.js
new Server({
routes() {
this.namespace = 'fakeApi'
// this.timing = 2000

// omit other code
}
})

Если вы раскомментируете эту строку, фейковый сервер добавит 2-секундную задержку каждому API-вызову, что даст нам достаточно времени для отображения индикатора загрузки.

Теперь мы можем прочитать состояние загрузки в компоненте <TodoList> и отображать индикатор загрузки в зависимости от его значения.

src/features/todos/TodoList.js
// omit imports

const TodoList = () => {
const todoIds = useSelector(selectFilteredTodoIds)
const loadingStatus = useSelector(state => state.todos.status)

if (loadingStatus === 'loading') {
return (
<div className="todo-list">
<div className="loader" />
</div>
)
}

const renderedListItems = todoIds.map(todoId => {
return <TodoListItem key={todoId} id={todoId} />
})

return <ul className="todo-list">{renderedListItems}</ul>
}

В реальном приложении мы также должны обрабатывать ошибки API и другие возможные сценарии.

Вот как выглядит приложение с включённым статусом загрузки (чтобы снова увидеть индикатор, перезагрузите превью приложения или откройте его в новой вкладке):

Стандартные действия Flux (Flux Standard Actions)

Хранилище Redux не требует строгой структуры объектов действий. Единственное обязательное условие — наличие строкового поля action.type. Технически вы можете добавлять любые другие поля: например, action.todo для действия "добавлена задача" или action.color и т.д.

Однако если каждое действие использует разные имена для полей данных, становится сложно предсказать, какие поля нужно обрабатывать в каждом редьюсере.

Поэтому сообщество Redux разработало конвенцию "Flux Standard Actions" (FSA). Это рекомендация по организации полей в объектах действий, позволяющая разработчикам однозначно понимать назначение полей. Паттерн FSA широко распространён в экосистеме Redux, и вы фактически использовали его на протяжении всего туториала.

Согласно конвенции FSA:

  • Если действие содержит данные, основное значение должно помещаться в action.payload

  • Действие может включать поле action.meta для дополнительной описательной информации

  • Действие может содержать поле action.error с информацией об ошибках

Таким образом, ВСЕ действия в Redux ОБЯЗАНЫ:

  • Быть простыми JavaScript-объектами

  • Иметь поле type

При следовании FSA действие МОЖЕТ содержать:

  • Поле payload

  • Поле error

  • Поле meta

Detailed Explanation: FSAs and Errors

The FSA specification says that:

The optional error property MAY be set to true if the action represents an error. An action whose error is true is analogous to a rejected Promise. By convention, the payload SHOULD be an error object. If error has any other value besides true, including undefined and null, the action MUST NOT be interpreted as an error.

The FSA specs also argue against having specific action types for things like "loading succeeded" and "loading failed".

However, in practice, the Redux community has ignored the idea of using action.error as a boolean flag, and instead settled on separate action types, like 'todos/todosLoadingSucceeded' and 'todos/todosLoadingFailed'. This is because it's much easier to check for those action types than it is to first handle 'todos/todosLoaded' and then check if (action.error).

You can do whichever approach works better for you, but most apps use separate action types for success and failure.

Нормализованное состояние (Normalized State)

До сих пор мы хранили задачи в виде массива. Это оправдано: данные приходят с сервера как массив, а для отображения списка в интерфейсе требуется их перебор.

Однако в крупных Redux-приложениях данные часто хранят в нормализованной структуре состояния. "Нормализация" подразумевает:

  • Гарантию единственной копии каждого элемента данных

  • Хранение элементов с возможностью прямой выборки по ID

  • Использование ссылок на другие элементы через ID вместо их полного копирования

Например, в блоговой системе объекты Post могут ссылаться на User и Comment. Если каждый Post включает целиком объект User, возникнет множество копий одного и того же объекта User. Вместо этого Post должен содержать ID пользователя (post.user), а сами объекты User храниться как state.users[post.user].

Такой подход предполагает организацию данных в виде объектов (а не массивов), где ключи — ID элементов, а значения — сами элементы:

const rootState = {
todos: {
status: 'idle',
entities: {
2: { id: 2, text: 'Buy milk', completed: false },
7: { id: 7, text: 'Clean room', completed: true }
}
}
}

Конвертируем срез задач в нормализованную форму. Это потребует значительных изменений в логике редьюсеров и обновления селекторов:

src/features/todos/todosSlice
const initialState = {
status: 'idle',
entities: {}
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
const todo = action.payload
return {
...state,
entities: {
...state.entities,
[todo.id]: todo
}
}
}
case 'todos/todoToggled': {
const todoId = action.payload
const todo = state.entities[todoId]
return {
...state,
entities: {
...state.entities,
[todoId]: {
...todo,
completed: !todo.completed
}
}
}
}
case 'todos/colorSelected': {
const { color, todoId } = action.payload
const todo = state.entities[todoId]
return {
...state,
entities: {
...state.entities,
[todoId]: {
...todo,
color
}
}
}
}
case 'todos/todoDeleted': {
const newEntities = { ...state.entities }
delete newEntities[action.payload]
return {
...state,
entities: newEntities
}
}
case 'todos/allCompleted': {
const newEntities = { ...state.entities }
Object.values(newEntities).forEach(todo => {
newEntities[todo.id] = {
...todo,
completed: true
}
})
return {
...state,
entities: newEntities
}
}
case 'todos/completedCleared': {
const newEntities = { ...state.entities }
Object.values(newEntities).forEach(todo => {
if (todo.completed) {
delete newEntities[todo.id]
}
})
return {
...state,
entities: newEntities
}
}
case 'todos/todosLoading': {
return {
...state,
status: 'loading'
}
}
case 'todos/todosLoaded': {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
return {
...state,
status: 'idle',
entities: newEntities
}
}
default:
return state
}
}

// omit action creators

const selectTodoEntities = state => state.todos.entities

export const selectTodos = createSelector(selectTodoEntities, entities =>
Object.values(entities)
)

export const selectTodoById = (state, todoId) => {
return selectTodoEntities(state)[todoId]
}

Поскольку state.entities теперь объект (а не массив), для обновления данных используем вложенные операторы распространения объектов вместо массивных операций. Также объекты нельзя перебирать как массивы, поэтому в нескольких местах применяем Object.values(entities) для получения массива задач.

Хорошая новость: благодаря инкапсуляции состояния в селекторах интерфейс не требует изменений. Плохая новость: код редьюсеров стал длиннее и сложнее.

Часть проблемы заключается в том, что этот пример с приложением задач не является большим реальным приложением. Поэтому нормализация состояния не так полезна в этом конкретном случае, и потенциальные преимущества сложнее заметить.

К счастью, в Части 8: Современный Redux с Redux Toolkit мы увидим способы значительного сокращения логики редюсеров для управления нормализованным состоянием.

Пока важно понимать следующее:

  • Нормализация часто используется в Redux-приложениях

  • Основные преимущества — возможность поиска отдельных элементов по ID и гарантия существования только одной копии элемента в состоянии

Информация

Thunks и промисы

У нас остался последний паттерн для рассмотрения в этом разделе. Мы уже видели, как обрабатывать состояние загрузки в хранилище Redux на основе диспатчимых действий. Что если нам нужно использовать результаты thunk в компонентах?

При вызове store.dispatch(action) метод dispatch фактически возвращает action как результат. Промежуточное ПО (middleware) может изменить это поведение и вернуть другое значение.

Мы уже видели, что промежуточное ПО Redux Thunk позволяет передавать функцию в dispatch, вызывает её, а затем возвращает результат:

reduxThunkMiddleware.js
const reduxThunkMiddleware = storeAPI => next => action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
// Also, return whatever the thunk function returns
return action(storeAPI.dispatch, storeAPI.getState)
}

// Otherwise, it's a normal action - send it onwards
return next(action)
}

Это означает, что мы можем писать функции thunk, которые возвращают промис, и ожидать этот промис в наших компонентах.

У нас уже есть компонент <Header>, который диспатчит thunk для сохранения новых задач на сервере. Добавим состояние загрузки внутрь компонента <Header>, отключим поле ввода и покажем индикатор загрузки во время ожидания ответа сервера:

src/features/header/Header.js
const Header = () => {
const [text, setText] = useState('')
const [status, setStatus] = useState('idle')
const dispatch = useDispatch()

const handleChange = e => setText(e.target.value)

const handleKeyDown = async e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create and dispatch the thunk function itself
setStatus('loading')
// Wait for the promise returned by saveNewTodo
await dispatch(saveNewTodo(trimmedText))
// And clear out the text input
setText('')
setStatus('idle')
}
}

let isLoading = status === 'loading'
let placeholder = isLoading ? '' : 'What needs to be done?'
let loader = isLoading ? <div className="loader" /> : null

return (
<header className="header">
<input
className="new-todo"
placeholder={placeholder}
autoFocus={true}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={isLoading}
/>
{loader}
</header>
)
}

export default Header

Теперь при добавлении задачи мы увидим индикатор в шапке:

Приложение задач - индикатор загрузки в компоненте

Итоги изученного

Как вы видели, существует несколько дополнительных паттернов, широко используемых в Redux-приложениях. Эти паттерны не обязательны и могут потребовать больше кода изначально, но они дают преимущества: повторное использование логики, инкапсуляция деталей реализации, улучшение производительности приложения и упрощение поиска данных.

Информация

Подробнее о причинах существования этих паттернов и принципах использования Redux:

Вот как выглядит наше приложение после полной адаптации этих паттернов:

Резюме
  • Функции создателей действий (action creators) инкапсулируют подготовку объектов действий и thunk
    • Создатели действий могут принимать аргументы, содержать логику настройки и возвращать финальный объект действия или функцию thunk
  • Мемоизированные селекторы помогают улучшить производительность Redux-приложений
    • Reselect предоставляет API createSelector для генерации мемоизированных селекторов
    • Мемоизированные селекторы возвращают ту же ссылку на результат при одинаковых входных данных
  • Статус запросов следует хранить как enum, а не булевы значения
    • Использование перечислений вроде 'idle' (бездействие) и 'loading' (загрузка) обеспечивает согласованное отслеживание статуса
  • "Стандартные действия Flux" — общепринятая конвенция для организации объектов действий
    • Действия используют payload для данных, meta для дополнительных описаний и error для ошибок
  • Нормализованное состояние упрощает поиск элементов по ID
    • Нормализованные данные хранятся в объектах вместо массивов, с ID элементов в качестве ключей
  • Thunk могут возвращать промисы из dispatch
    • Компоненты могут ожидать завершения асинхронных thunk перед выполнением дальнейших действий

Что дальше?

Писать весь этот код "вручную" может быть трудоёмко и сложно. Поэтому мы рекомендуем использовать наш официальный пакет Redux Toolkit для написания логики Redux.

Redux Toolkit включает API, которые помогают реализовать все типичные паттерны использования Redux с меньшим объёмом кода. Он также помогает предотвращать распространённые ошибки, например случайное изменение состояния.

В Части 8: Современный Redux мы рассмотрим, как использовать Redux Toolkit для упрощения всего написанного нами кода.