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

Redux Fundamentals, Part 5: UI и React

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

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

Что вы узнаете
  • Как хранилище Redux взаимодействует с пользовательским интерфейсом
  • Как использовать Redux вместе с React

Введение

В Части 4: Хранилище мы рассмотрели создание хранилища Redux, диспетчеризацию действий и чтение текущего состояния. Мы также изучили внутреннюю работу хранилища, как усилители (enhancers) и промежуточное ПО (middleware) позволяют расширять его функциональность, и как добавить Redux DevTools для отслеживания процессов в приложении при диспетчеризации действий.

В этом разделе мы добавим пользовательский интерфейс для нашего приложения с задачами. Мы рассмотрим, как Redux взаимодействует с UI-слоем в целом, и отдельно остановимся на интеграции Redux с React.

Внимание!

Обратите внимание: на этой и всех последующих страницах учебника "Основы" используется современный hooks API React-Redux. Устаревший API connect по-прежнему работает, но сегодня мы рекомендуем всем пользователям Redux перейти на hooks API.

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

Полные примеры "правильного использования Redux" с Redux Toolkit и React-Redux hooks для реальных приложений смотрите в учебнике "Redux Essentials".

Интеграция Redux с пользовательским интерфейсом

Redux — это самостоятельная JavaScript-библиотека. Как мы уже видели, вы можете создавать и использовать хранилище Redux даже без настройки пользовательского интерфейса. Это означает, что Redux можно использовать с любым UI-фреймворком (или вообще без него), как на клиенте, так и на сервере. Вы можете создавать приложения на Redux с React, Vue, Angular, Ember, jQuery или на чистом JavaScript.

Тем не менее Redux изначально разрабатывался для эффективной работы с React. React позволяет описывать интерфейс как функцию состояния, а Redux хранит состояние и обновляет его в ответ на действия.

Поэтому в этом учебнике мы будем использовать React для создания приложения с задачами и рассмотрим основы интеграции Redux с React.

Прежде чем перейти к этой части, кратко рассмотрим, как Redux взаимодействует с UI-слоем в общем случае.

Базовая интеграция Redux с UI

Использование Redux с любым UI-слоем включает несколько стандартных шагов:

  1. Создать хранилище Redux

  2. Подписаться на обновления

  3. В колбэке подписки:

    1. Получить текущее состояние хранилища
    2. Извлечь данные, необходимые для данного UI-компонента
    3. Обновить интерфейс этими данными
  4. При необходимости отрисовать интерфейс с начальным состоянием

  5. Обрабатывать пользовательский ввод через диспетчеризацию действий Redux

Вернёмся к примеру счётчика из Части 1 и посмотрим, как он следует этим шагам:

// 1) Create a new Redux store with the `createStore` function
const store = Redux.createStore(counterReducer)

// 2) Subscribe to redraw whenever the data changes in the future
store.subscribe(render)

// Our "user interface" is some text in a single HTML element
const valueEl = document.getElementById('value')

// 3) When the subscription callback runs:
function render() {
// 3.1) Get the current store state
const state = store.getState()
// 3.2) Extract the data you want
const newValue = state.value.toString()

// 3.3) Update the UI with the new value
valueEl.innerHTML = newValue
}

// 4) Display the UI with the initial store state
render()

// 5) Dispatch actions based on UI inputs
document.getElementById('increment').addEventListener('click', function () {
store.dispatch({ type: 'counter/incremented' })
})

Redux работает одинаково с любым UI-слоем. Реальные реализации обычно сложнее для оптимизации производительности, но последовательность шагов остаётся неизменной.

Поскольку Redux — отдельная библиотека, существуют специальные "биндинги" для интеграции с различными UI-фреймворками. Эти библиотеки берут на себя подписку на хранилище и эффективное обновление интерфейса при изменениях состояния, избавляя вас от необходимости писать этот код самостоятельно.

Использование Redux с React

Официальная библиотека React-Redux UI bindings поставляется отдельным пакетом от ядра Redux. Её нужно установить дополнительно:

npm install react-redux

В этом руководстве мы рассмотрим ключевые шаблоны и примеры совместного использования React и Redux, а также увидим их работу на практике в нашем todo-приложении.

Информация

Полное руководство по совместному использованию Redux и React смотрите в официальной документации React-Redux: https://react-redux.js.org. Там же вы найдёте справочник по API React-Redux.

Проектирование дерева компонентов

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

Исходя из списка бизнес-требований, нам понадобится как минимум такой набор компонентов:

  • <App>: корневой компонент, рендерящий всё остальное.
    • <Header>: содержит текстовое поле ввода для новых задач и чекбокс "выполнить все задачи"
    • <TodoList>: список текущих задач, основанный на результатах фильтрации
      • <TodoListItem>: элемент списка задач с чекбоксом для изменения статуса выполнения и выбором цветовой категории
    • <Footer>: отображает количество активных задач и элементы управления для фильтрации по статусу выполнения и цветовым категориям

Эту структуру можно разделить по-разному. Например, компонент <Footer> можно оставить цельным или разбить на мелкие компоненты вроде <CompletedTodos>, <StatusFilter> и <ColorFilters>. Не существует единственно верного подхода — выбор между крупными компонентами и их разделением зависит от ситуации.

Мы начнём с этого минимального набора для простоты понимания. Поскольку мы предполагаем ваше знакомство с React, мы пропустим детали вёрстки и сосредоточимся на использовании React-Redux в компонентах.

Вот первоначальный пользовательский интерфейс на React для этого приложения до добавления какой-либо логики, связанной с Redux:

Чтение состояния из хранилища с помощью useSelector

Начнём с создания компонента <TodoList>, который сможет прочитать список задач из хранилища, пройтись по ним и отобразить компонент <TodoListItem> для каждой задачи.

Вы знакомы с React-хуками вроде useState, которые дают функциональным компонентам доступ к состоянию. React также позволяет создавать пользовательские хуки для повторного использования логики поверх встроенных хуков.

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

Первый хук, который мы рассмотрим — useSelector, который позволяет React-компонентам читать данные из Redux-хранилища.

useSelector принимает одну функцию — селектор. Селектор — это функция, которая принимает всё состояние Redux-хранилища, извлекает из него нужное значение и возвращает результат.

Например, мы знаем, что состояние Redux в нашем приложении для заметок хранит массив задач в state.todos. Мы можем написать небольшую функцию-селектор, которая возвращает этот массив задач:

const selectTodos = state => state.todos

Или, возможно, мы хотим узнать, сколько задач в данный момент помечено как "выполненные":

const selectTotalCompletedTodos = state => {
const completedTodos = state.todos.filter(todo => todo.completed)
return completedTodos.length
}

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

Давайте загрузим массив задач в наш компонент <TodoList>. Сначала импортируем хук useSelector из библиотеки react-redux, затем вызовем его с функцией-селектором в качестве аргумента:

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

const selectTodos = state => state.todos

const TodoList = () => {
const todos = useSelector(selectTodos)

// since `todos` is an array, we can loop over it
const renderedListItems = todos.map(todo => {
return <TodoListItem key={todo.id} todo={todo} />
})

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

export default TodoList

При первом рендере компонента <TodoList> хук useSelector вызовет selectTodos и передаст ему весь объект состояния Redux. Что бы селектор ни вернул, хук передаст это значение в ваш компонент. Таким образом, const todos в нашем компоненте будет содержать тот же массив state.todos, что и в состоянии хранилища Redux.

Но что произойдёт, если мы отправим действие вроде {type: 'todos/todoAdded'}? Redux обновит состояние через редюсер, но наш компонент должен узнать об изменениях, чтобы перерендериться с новым списком задач.

Мы знаем, что можем подписаться на изменения хранилища через store.subscribe(), поэтому теоретически можем добавить эту логику в каждый компонент. Но это быстро станет утомительным и сложным для поддержки.

К счастью, useSelector автоматически подписывается на хранилище Redux за нас! Теперь при любом диспатче действия хук немедленно перезапустит функцию-селектор. Если возвращаемое значение изменится по сравнению с предыдущим вызовом, useSelector принудительно вызовет перерендер компонента с новыми данными. Нам достаточно лишь один раз вызвать useSelector() в компоненте — остальное хук сделает сам.

Однако здесь важно помнить:

Внимание!

useSelector сравнивает результаты через строгое равенство по ссылке (===), поэтому компонент перерендерится при каждом возврате новой ссылки! Это означает, что если селектор создаёт и возвращает новую ссылку, ваш компонент может перерендериваться после каждого диспатча действия, даже если данные фактически не изменились.

Например, передача такого селектора в useSelector вызовет постоянный перерендер компонента, потому что array.map() всегда возвращает новый массив:

// Bad: always returning a new reference
const selectTodoDescriptions = state => {
// This creates a new array reference!
return state.todos.map(todo => todo.text)
}
Совет

Мы обсудим способы решения этой проблемы далее в разделе. Также в Части 7: Стандартные шаблоны Redux мы рассмотрим оптимизацию производительности и предотвращение лишних перерендеров через "мемоизированные" селекторы.

Также отметим, что функцию-селектор можно писать непосредственно при вызове useSelector:

const todos = useSelector(state => state.todos)

Диспатч действий с useDispatch

Теперь мы умеем читать данные из Redux-хранилища в компонентах. Но как отправлять действия в хранилище из компонента? Вне React мы можем вызывать store.dispatch(action). Поскольку у нас нет доступа к хранилищу в компоненте, нам нужен способ получить функцию dispatch внутри компонента.

Хук React-Redux useDispatch возвращает метод dispatch хранилища. (Фактически, реализация хука — это return store.dispatch.)

Таким образом, в любом компоненте, которому нужно отправлять действия, мы можем вызвать const dispatch = useDispatch(), а затем при необходимости вызывать dispatch(someAction).

Попробуем реализовать это в компоненте <Header>. Нам нужно позволить пользователю ввести текст новой задачи и отправить действие {type: 'todos/todoAdded'} с этим текстом.

Создадим типичный React-компонент формы с "управляемыми полями ввода", чтобы пользователь мог вводить текст. При нажатии клавиши Enter будем отправлять соответствующее действие.

src/features/header/Header.js
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'

const Header = () => {
const [text, setText] = useState('')
const dispatch = useDispatch()

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

const handleKeyDown = e => {
const trimmedText = e.target.value.trim()
// If the user pressed the Enter key:
if (e.key === 'Enter' && trimmedText) {
// Dispatch the "todo added" action with this text
dispatch({ type: 'todos/todoAdded', payload: trimmedText })
// And clear out the text input
setText('')
}
}

return (
<input
type="text"
placeholder="What needs to be done?"
autoFocus={true}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
)
}

export default Header

Передача хранилища через Provider

Наши компоненты уже могут читать состояние из хранилища и отправлять действия. Но остаётся вопрос: как хуки React-Redux находят нужное хранилище? Хук — это обычная JS-функция, которая не может автоматически импортировать хранилище из store.js.

Вместо этого мы должны явно указать React-Redux, какое хранилище использовать в наших компонентах. Для этого мы оборачиваем всё приложение <App> в компонент <Provider> и передаём Redux-хранилище как пропс в <Provider>. После однократного выполнения этих действий любой компонент приложения сможет получить доступ к Redux-хранилищу при необходимости.

Добавим это в главный файл index.js:

src/index.js
import React from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'

import App from './App'
import store from './store'

const root = createRoot(document.getElementById('root'))

root.render(
// Render a `<Provider>` around the entire `<App>`,
// and pass the Redux store to it as a prop
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)

Ключевые аспекты использования React-Redux с React:

  • Чтение данных в компонентах через хук useSelector

  • Отправка действий через хук useDispatch

  • Оберните весь ваш компонент <App> в <Provider store={store}>, чтобы другие компоненты могли взаимодействовать с хранилищем

Теперь приложением можно взаимодействовать! Вот текущий рабочий интерфейс:

Рассмотрим ещё несколько паттернов их совместного использования в нашем приложении.

Паттерны React-Redux

Глобальное состояние, состояние компонента и формы

К этому моменту вы могли задуматься: "Должен ли я всегда помещать всё состояние приложения в хранилище Redux?"

Ответ: НЕТ. Глобальное состояние, необходимое всему приложению, должно находиться в хранилище Redux. Состояние, которое нужно только в одном месте, должно оставаться в состоянии компонента.

Хороший пример — компонент <Header>, который мы написали ранее. Технически мы могли бы хранить текущий текст в Redux, отправляя действие в обработчике onChange поля ввода и сохраняя его в редьюсере. Но это не даёт преимуществ, так как текст используется только здесь, в компоненте <Header>.

Поэтому логично хранить это значение в хуке useState внутри компонента <Header>.

Аналогично, если бы у нас был булев флаг isDropdownOpen, ни один другой компонент приложения не использовал бы его — он действительно должен оставаться локальным для этого компонента.

Совет

В React + Redux приложениях глобальное состояние должно храниться в Redux, а локальное — в компонентах React.

Если сомневаетесь, куда поместить данные, используйте эмпирические правила:

  • Используют ли другие части приложения эти данные?
  • Нужно ли вычислять производные данные на их основе?
  • Используются ли данные в нескольких компонентах?
  • Требуется ли возможность восстановления состояния (например, для отладки с "путешествием во времени")?
  • Нужно ли кэшировать данные (использовать существующее состояние вместо повторных запросов)?
  • Важно ли сохранять консистентность данных при горячей замене компонентов?

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

Использование нескольких селекторов в компоненте

Сейчас только наш компонент <TodoList> читает данные из хранилища. Давайте посмотрим, как может выглядеть ситуация, когда компонент <Footer> тоже начнёт читать данные.

Компоненту <Footer> требуется знать три фрагмента информации:

  • Количество выполненных задач (todos)

  • Текущее значение фильтра "status"

  • Текущий список выбранных фильтров категории "color"

Как мы можем получить эти значения в компоненте?

Мы можем вызывать useSelector несколько раз в одном компоненте. Более того, это хорошая практика — каждый вызов useSelector должен возвращать минимально необходимый фрагмент состояния.

Мы уже видели, как написать селектор для подсчёта выполненных задач ранее. Что касается фильтров, и значение фильтра по статусу, и значения фильтров по цвету находятся в срезе state.filters. Поскольку компоненту нужны оба, мы можем выбрать весь объект state.filters.

Как мы упоминали ранее, мы могли бы поместить всю обработку ввода прямо в <Footer> или вынести её в отдельные компоненты вроде <StatusFilter>. Для краткости мы опустим детали реализации обработки ввода и предположим, что у нас есть небольшие компоненты, получающие данные и колбэки-обработчики изменений через пропсы.

При таком подходе части компонента, связанные с React-Redux, могут выглядеть так:

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

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

// Omit other footer components

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

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

// omit placeholder change handlers

return (
<footer className="footer">
<div className="actions">
<h5>Actions</h5>
<button className="button">Mark All Completed</button>
<button className="button">Clear Completed</button>
</div>

<RemainingTodos count={todosRemaining} />
<StatusFilter value={status} onChange={onStatusChange} />
<ColorFilters value={colors} onChange={onColorChange} />
</footer>
)
}

export default Footer

В настоящее время наш <TodoList> читает весь массив state.todos и передаёт фактические объекты задач в качестве пропса каждому компоненту <TodoListItem>.

Это работает, но существует потенциальная проблема с производительностью.

  • Изменение одного объекта todo требует создания копий как самого todo, так и массива state.todos, причём каждая копия представляет собой новую ссылку в памяти

  • Когда useSelector обнаруживает новую ссылку в результате, он принудительно вызывает повторный рендеринг своего компонента

  • Итак, при каждом обновлении одного объекта todo (например, при клике для переключения его статуса завершения), весь родительский компонент <TodoList> будет перерендерен

  • Таким образом, поскольку React по умолчанию рекурсивно перерисовывает все дочерние компоненты, это также означает, что все компоненты <TodoListItem> будут перерисованы, даже если большинство из них вообще не изменились!

Перерендеринг компонентов — это не плохо, так React узнаёт, нужно ли обновлять DOM. Однако, перерендеринг множества компонентов, когда ничего не изменилось, может стать слишком медленным, если список очень большой.

Есть несколько способов исправить это. Один из вариантов — обернуть все компоненты <TodoListItem> в React.memo(), чтобы они перерендеривались только при реальном изменении пропсов. Это часто улучшает производительность, но требует, чтобы дочерний компонент всегда получал одинаковые пропсы до фактических изменений. Поскольку каждый <TodoListItem> получает элемент списка дел как проп, то измениться должен только один из них, и только он перерендерится.

Другой вариант — сделать так, чтобы компонент <TodoList> считывал из хранилища только массив идентификаторов задач и передавал эти идентификаторы как пропсы дочерним компонентам <TodoListItem>. Затем каждый <TodoListItem> может использовать этот идентификатор для поиска нужного объекта задачи.

Другой вариант — заставить TodoList читать из хранилища только массив ID задач, передавая эти ID как пропсы в дочерние TodoListItem. Затем каждый компонент сможет найти нужный объект задачи по ID. Давайте попробуем этот подход.

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

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

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

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

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

На этот раз мы выбираем из хранилища только массив идентификаторов задач в компоненте <TodoList>, а затем передаём каждый todoId как проп id в дочерние компоненты <TodoListItem>.

Затем в компоненте <TodoListItem> мы можем использовать этот ID для чтения соответствующей задачи. Также мы можем обновить <TodoListItem> для диспетчеризации действия "toggled" на основе ID задачи.

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

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

const selectTodoById = (state, todoId) => {
return state.todos.find(todo => todo.id === todoId)
}

// Destructure `props.id`, since we only need the ID value
const TodoListItem = ({ id }) => {
// Call our `selectTodoById` with the state _and_ the ID value
const todo = useSelector(state => selectTodoById(state, id))
const { text, completed, color } = todo

const dispatch = useDispatch()

const handleCompletedChanged = () => {
dispatch({ type: 'todos/todoToggled', payload: todo.id })
}

// omit other change handlers
// omit other list item rendering logic and contents

return (
<li>
<div className="view">{/* omit other rendering output */}</div>
</li>
)
}

export default TodoListItem

Однако здесь возникает проблема. Ранее мы упоминали, что возврат новых ссылок на массивы в селекторах приводит к перерисовке компонентов при каждом обновлении, и сейчас мы возвращаем новый массив ID в <TodoList>. В данном случае содержимое массива ID должно оставаться неизменным при переключении задачи, поскольку мы отображаем те же задачи — мы не добавляли и не удаляли их. Но массив, содержащий эти ID, представляет собой новую ссылку, поэтому <TodoList> будет перерисовываться без реальной необходимости.

Одно из возможных решений — изменить способ сравнения значений в useSelector. useSelector может принимать функцию сравнения в качестве второго аргумента. Эта функция вызывается со старым и новым значениями и возвращает true, если они считаются одинаковыми. В этом случае useSelector не будет инициировать перерисовку компонента.

React-Redux предоставляет функцию сравнения shallowEqual, которая проверяет, остались ли элементы внутри массива теми же. Попробуем её использовать:

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

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

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

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

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

Теперь при переключении задачи список ID будет считаться неизменным, и <TodoList> не потребуется перерисовываться. Один компонент <TodoListItem> получит обновлённый объект задачи и перерисуется, а остальные продолжат использовать существующие объекты задач без перерисовки.

Как упоминалось ранее, для оптимизации рендеринга компонентов можно использовать специальные селекторы — «мемоизированные селекторы», которые мы рассмотрим в другом разделе.

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

Теперь у нас есть рабочее приложение для управления задачами! Оно создаёт хранилище, передаёт его в React-слой через <Provider>, а затем использует useSelector и useDispatch для взаимодействия с хранилищем в React-компонентах.

Информация

Попробуйте самостоятельно реализовать оставшиеся функции интерфейса! Вот что нужно добавить:

  • В компоненте <TodoListItem> используйте хук useDispatch для диспетчеризации действий изменения цветовой категории и удаления задачи
  • В компоненте <Footer> используйте useDispatch для действий: отметки всех задач как завершённых, очистки завершённых задач и изменения значений фильтров

Реализацию фильтров мы рассмотрим в Части 7: Стандартные шаблоны Redux.

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

Ключевые моменты
  • Хранилища Redux можно использовать с любым UI-слоем
    • UI-код всегда подписывается на хранилище, получает актуальное состояние и перерисовывается
  • React-Redux — официальная библиотека для интеграции Redux с React
    • Устанавливается как отдельный пакет react-redux
  • Хук useSelector позволяет React-компонентам читать данные из хранилища
    • Функции-селекторы принимают всё состояние хранилища state и возвращают значение на его основе
    • useSelector вызывает селектор и возвращает его результат
    • useSelector подписывается на хранилище и перезапускает селектор при каждом диспетчеризации действия
    • При изменении результата селектора useSelector инициирует перерисовку компонента с новыми данными
  • Хук useDispatch позволяет React-компонентам диспетчеризовать действия в хранилище
    • useDispatch возвращает оригинальную функцию store.dispatch
    • Вы можете вызывать dispatch(action) непосредственно в компонентах
  • Компонент <Provider> делает хранилище доступным для других React-компонентов
    • Оборачивайте всё приложение <App> в <Provider store={store}>

Что дальше?

Теперь, когда наш пользовательский интерфейс работает, пришло время разобраться, как заставить наше Redux-приложение взаимодействовать с сервером. В Части 6: Асинхронная логика мы рассмотрим, как асинхронные операции, такие как таймауты и HTTP-запросы, интегрируются в поток данных Redux.