Главная / Статьи / Как React-Redux принимает решение о перерисовке: селекторы, проверка равенства и типизированные хуки

Как React-Redux принимает решение о перерисовке: селекторы, проверка равенства и типизированные хуки

Руководство по изучению внутреннего устройства React-Redux: Provider, проверка равенства с useSelector, мемоизированные селекторы, функция connect(), типизированные хуки с withTypes() и редкие крайние случаи.

7738 слов

На первый взгляд, архитектура React-Redux кажется очень простой: компонент provider и несколько хуков. Однако в крупных проектах постоянно возникают одни и те же вопросы. Почему этот компонент отрисовывается при каждом вызове dispatch? Почему селектор, возвращающий объект, ведет себя иначе, чем mapStateToProps? Когда shallowEqual является подходящим инструментом, что на самом деле описывают RootState и AppDispatch, и нужен ли batch() в React 18?

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

useSelector()
useDispatch()
<Provider />

Текущие рекомендации: разработчики React-Redux советуют использовать API Hooks в качестве стандарта для компонентов. Функция connect() по-прежнему поддерживается и имеет значение, поскольку от неё зависит множество существующих кодов.

Что такое React-Redux и какова его роль

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

Redux
  │
  │ Application State
  ▼
React-Redux
  │
  │ Integration
  ▼
React
  │
  │ UI
  ▼
User

Конкретно, этот механизм связи предоставляет компонентам четыре возможности:

  • чтение значений из состояния Redux
  • подписка на изменения этих значений
  • отправка действий в хранилище
  • интеграция обновлений хранилища в цикл отрисовки React

Современные способы реализации этих операций — это три хука:

useSelector()
useDispatch()
useStore()

Плюс компонент, который делает хранилище доступным в первую очередь:

<Provider />

Для более старого кода существует также API высшего порядка для компонентов, которое по-прежнему полностью поддерживается:

connect()

Redux и React-Redux — это разные пакеты

Сам Redux является контейнером состояния и включает в себя следующие концепции:

Store
State
Actions
Reducers
Dispatch
Middleware
Selectors

React-Redux — это лишь мост, и всё, что он экспортирует, связано с взаимодействием с React:

Provider
useSelector
useDispatch
useStore
connect

Когда все слои объединены вместе, они выглядят так:

              Redux
        ┌───────────────┐
        │ Store         │
        │ State         │
        │ Reducers      │
        │ Actions       │
        │ Middleware    │
        └───────┬───────┘
                │
                ▼
          React-Redux
        ┌───────────────┐
        │ Provider      │
        │ useSelector   │
        │ useDispatch   │
        │ useStore      │
        │ connect       │
        └───────┬───────┘
                │
                ▼
              React

Если вопрос касается того, как меняется состояние (редьюсеры, мидлвары, формат действий), он относится к Redux. Если же речь идет о том, когда компонент замечает изменения или отрисовывается, это относится к React-Redux.

Основной цикл, который нужно запомнить

Прежде чем использовать любой API, необходимо усвоить этот цикл: сначала прочитать селектор, затем обработать взаимодействие, изменить состояние, снова пройтись по селектору и отрендерить содержимое только в случае изменения выбранного значения.

                 React Component
                       │
                       │ useSelector()
                       ▼
                 Redux Store
                       │
                       │ current state
                       ▼
                    Component
                       │
                       │ user interaction
                       ▼
                 useDispatch()
                       │
                       │ dispatch(action)
                       ▼
                    Reducer
                       │
                       ▼
                  New Redux State
                       │
                       ▼
                 useSelector()
                       │
                       ▼
                Re-render if
                 selected value
                   changed

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

Provider: обеспечение доступа к хранилищу данных

Хуки могут взаимодействовать только с хранилищем данных, находящимся выше них в иерархии. Компонент <Provider> отвечает за его размещение там. Обычно корень приложения оборачивается им один раз:

import { Provider } from 'react-redux'
import { store } from './store'
function AppRoot() {
  return (
    <Provider store={store}>
      <App />
    </Provider>
  )
}

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

<Provider store={store}>
       │
       ├── App
       │    ├── Header
       │    ├── Dashboard
       │    ├── UserProfile
       │    └── Settings
       │
       └── Every descendant
            can use Redux

Если компонент вызывает один из хуков вне Provider, нет никакого хранилища данных для чтения, и хук терпит неудачу (на практике он выбрасывает ошибку с сообщением о отсутствии значения контекста):

useSelector(...)
useDispatch(...)

Часто это приводит к проблемам в единичных тестах, где компонент отрисовывается без оборачивания его в тестовый Provider.

useSelector: как компоненты читают состояние

useSelector() — это хук, который вы будете использовать чаще всего. Вы передаете ему функцию, которая принимает всё состояние Redux и возвращает ту часть, которая нужна компоненту:

const count = useSelector(
  state => state.counter.value
)

Структура потока данных проста:

Redux State
     │
     ▼
useSelector()
     │
     ▼
Selected Value
     │
     ▼
React Component

Поскольку React-Redux может вызывать ваш селектор чаще, чем вы ожидаете (при отрисовке, после отправки действий, во время проверок в режиме разработки), селектор должен быть чистым: одинаковый вход — одинаковый выход, без побочных эффектов.

Что делает хук при каждой отрисовке и отправке действия

Возьмем селектор, который выбирает текущего пользователя:

const user = useSelector(
  state => state.auth.user
)

За этой одной строкой React-Redux выполняет небольшую процедуру:

  1. читает текущее состояние хранилища
  2. передает его в ваш селектор
  3. сохраняет возвращенное значение как результат «последнего выбора»
  4. регистрирует подписку на хранилище для этого компонента
  5. снова запускает селектор после каждого отправленного действия
  6. сравнивает предыдущий результат с новым
  7. планирует перерисовку только тогда, когда сравнение показывает их различие

Именно шаг 6 является причиной почти всех неожиданных поведений.

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

По умолчанию хук

useSelector()

сравнивает результаты с ===:

previousResult === newResult

Когда сравнение дает

true

Селектор не дает React-Redux причин снова отрисовывать компонент. Когда он возвращает

false

компонент планируется к перерисовке.

Это намеренное отличие от connect(), который выполняет поверхностное сравнение объекта, возвращаемого mapStateToProps. Код, который работал нормально с connect(), может начать отрисовываться при каждом действии после переноса на хуки без изменения селектора.

Почему возврат нового объекта приводит к перерисовке каждый раз

Естественная первая попытка получения двух значений выглядит так:

const data = useSelector(state => ({
  user: state.user,
  count: state.count
}))

С самими значениями всё в порядке, но функция-стрелка создаёт совершенно новый объект каждый раз, когда выполняется:

{
  user: ...,
  count: ...
}

Два объекта с идентичным содержимым по-прежнему считаются двумя разными объектами, поэтому проверка

oldObject === newObject

всегда даёт результат

false

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

Action dispatched
      ↓
Selector executes
      ↓
New object created
      ↓
Different reference
      ↓
Component re-renders

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

Решение первое: вызывайте useSelector один раз на каждое значение

Вместо объединения значений в объект,

const data = useSelector(state => ({
  user: state.user,
  count: state.count
}))

разделите операцию чтения на отдельные хуки:

const user = useSelector(
  state => state.user
)
const count = useSelector(
  state => state.count
)

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

user unchanged
count unchanged

ссылки остаются идентичными, и проверка === проходит успешно.

Не существует никакого штрафа за несколько вызовов хука в одном компоненте. Если один dispatch изменяет более одного из выбранных значений, React-Redux группирует полученные обновления, поэтому компонент отрисовывается всего один раз для этого dispatch, а не по одному разу на каждый хук.

Решение два: shallowEqual для целенаправленных объектов

Иногда наиболее подходящим вариантом действительно является возврат объекта, например когда компонент использует небольшой набор связанных полей. В таком случае передайте shallowEqual в качестве функции сравнения:

import {
  useSelector,
  shallowEqual
} from 'react-redux';

const data = useSelector(
  state => ({
    user: state.user,
    count: state.count
  }),
  shallowEqual
)

С её помощью React-Redux сравнивает каждое поле верхнего уровня старого и нового объектов с помощью ===, вместо того чтобы сравнивать ссылки на объекты. Новый обёрточный объект с теми же значениями полей считается неизменным.

В новых версиях также возможно указать функцию сравнения через объект параметров:

const data = useSelector(
  selector,
  {
    equalityFn: shallowEqual
  }
)

Когда стоит использовать shallowEqual

Используйте shallowEqual, когда группировка значений делает компонент более читаемым. Не рассматривайте его как стандартную функцию, применяемую автоматически при каждом вызове:

useSelector(selector, shallowEqual)

Лучший первый вопрос — может ли компонент просто выбирать каждое значение отдельно. В большинстве случаев это возможно, и результат становится более удобным для просмотра:

const user = useSelector(
  state => state.auth.user
)

const permissions = useSelector(
  state => state.auth.permissions
)

Имейте в виду, что shallowEqual проверяет только один уровень глубины. Если поле в возвращаемом объекте само по себе является новым массивом или объектом, сравнение всё равно провалится.

Селекторы, вычисляющие данные

Селекторы выполняют не только извлечение полей. Именно в них также вычисляются производные данные, такие как отфильтрованный список:

const selectCompletedTodos = state =>
  state.todos.filter(
    todo => todo.completed
  )

Проблема в том, что

filter()

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

oldArray !== newArray

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

Same inputs
    ↓
Return cached result

Когда входные данные меняются, вычисления выполняются заново:

Changed inputs
    ↓
Recalculate

createSelector из Reselect

Стандартным инструментом для этого является функция createSelector из библиотеки Reselect (она переэкспортируется в Redux Toolkit). Вы указываете селекторы входных данных, а затем функцию-результат, которая выполняется только при изменении этих селекторов:

import { createSelector } from 'reselect';

const selectCompletedTodos = createSelector(
  state => state.todos,
  todos =>
    todos.filter(todo => todo.completed)
)

Компонент использует её как любой другой селектор:

const todos = useSelector(
  selectCompletedTodos
)

Поскольку state.todos представляет собой тот же самый ссылочный массив, селектор возвращает его предыдущий отфильтрованный массив, поэтому оператор === возвращает истину. Однако существует нюанс: мемоизированный селектор хранит кэш, поэтому место его создания имеет значение.

Создавайте мемоизированные селекторы, зависящие только от состояния, один раз на уровне модуля

Когда мемоизированный селектор зависит только от состояния Redux,

const selectCompletedTodos = createSelector(
  state => state.todos,
  todos => ...
)

объявляйте его вне любого компонента:

const selectCompletedTodos = createSelector(...)

и обращайтесь к нему из компонента:

function TodoList() {
  const todos = useSelector(
    selectCompletedTodos
  )
}

Если функция createSelector выполняется внутри тела компонента, при каждом отрисовке создается новый селектор с пустым кэшем, и мемоизация не будет полезна. Инстанция на уровне модуля сохраняется между отрисовками.

Мемоизированные селекторы, которые также нуждаются в параметрах

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

function TodoListItem({ id }) {
  const todo = useSelector(
    state => state.todos[id]
  )
return <div>{todo.text}</div>
}

Ситуация становится более сложной, когда мемоизированный селектор зависит одновременно и от состояния, и от параметров

Redux State + Component Props

Такой селектор кэширует результат для своих последних аргументов. Если множество элементов списка используют один экземпляр, но имеют разные id, они постоянно аннулируют кэш друг друга. Для одного компонента обычно достаточно создать селектор с помощью useMemo; для множества компонентов необходимо знать стратегию мемоизации вашей библиотеки (размер кэша или один экземпляр на компонент). Этот вопрос часто возникает на собеседованиях для старших специалистов и при работе с длинными списками.

Селекторы должны оставаться чистыми

Селектор должен быть простой функцией состояния:

State
  ↓
Selector
  ↓
Value

и никогда не должен служить местом, куда «утекает» внешняя логика:

State
  ↓
Selector
  ↓
API call
  ↓
Mutation
  ↓
Side effect

Следует избегать таких селекторов; логирование, сетевые запросы и изменения данных не должны находиться здесь:

const selectUser = state => {
  console.log('side effect')
  // API call ❌
  // mutation ❌
  return state.user
}

Использование props внутри селектора

Селектор, определенный непосредственно внутри компонента, может просто использовать props этого компонента:

function TodoItem({ id }) {
  const todo = useSelector(
    state => state.todos[id]
  )
return <div>{todo.text}</div>
}

Значение передается от свойства к селектору через замыкание:

id
 ↓
closure
 ↓
selector
 ↓
state.todos[id]

Это существенная разница по сравнению с mapStateToProps, который принимает ownProps в качестве второго аргумента. useSelector() совсем не передает свойства, поэтому приходится полагаться на замыкания или фабрики селекторов, принимающие дополнительные аргументы.

useDispatch: отправка действий

Если useSelector() отвечает за чтение данных,

useSelector
     ↓
    READ

то useDispatch() отвечает за запись данных:

useDispatch
     ↓
  DISPATCH

Он возвращает функцию dispatch хранилища, которую вызывают из обработчиков событий:

const dispatch = useDispatch()

function handleClick() {
  dispatch(increment())
}

или непосредственно в JSX:

<button
  onClick={() => dispatch(increment())}
>
  Increment
</button>

Что запускается при вызове dispatch

Пошаговый анализ процесса нажатия кнопки подтверждает основной цикл, рассмотренный ранее:

User clicks button
       ↓
dispatch(action)
       ↓
Redux
       ↓
Reducer
       ↓
New state
       ↓
useSelector()
       ↓
Component updates

Конкретный вызов с создателем действия и нагрузкой выглядит так:

dispatch(
  addTodo({
    id: 1,
    text: 'Learn Redux'
  })
)

Стабильные обратные вызовы для мемоизированных дочерних элементов

Большинству обратных вызовов при отправке сигналов не требуется использование useCallback. Исключение составляют случаи, когда обработчик передается

const increment = () =>
  dispatch(incrementAction())

дочернему элементу, обёрнутому в React.memo:

<MyButton
  onIncrement={increment}
/>

Поскольку функция-стрелка создаётся заново при каждой отрисовке родителя, мемоизированный дочерний элемент каждый раз получает новое свойство и всё равно отрисовывается. Обёртка обработчика решает эту проблему:

const increment = useCallback(
  () => dispatch(incrementAction()),
  [dispatch]
)

вместе с мемоизированным дочерним элементом:

const MyButton = React.memo(...)

Указание dispatch в качестве зависимости безопасно: его идентичность остаётся прежней, пока компонент Provider получает тот же экземпляр хранилища.

useStore: прямой доступ, редко нужен

Третий хук предоставляет вам сам объект хранилища:

const store = useStore()

С его помощью можно вызывать прямые методы хранилища:

store.getState()
store.dispatch(...)
store.subscribe(...)

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

useSelector()

а запись — через

useDispatch()

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

Почему значение store.getState() в функции render устаревает

Рассмотрим компонент, который читает данные непосредственно из хранилища:

function Component() {
  const store = useStore()
  const user = store.getState().user
  return <div>{user.name}</div>
}

Он отображается правильно один раз, а затем отстает. getState() — это однократное чтение без подписки, поэтому последующие изменения никогда не заставляют React перерисовать этот компонент. Версия с подпиской остается синхронизированной:

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

Три хука в одном взгляде

┌────────────────────────────┐
│ React-Redux Hooks          │
├────────────────────────────┤
│ useSelector()              │ → READ
│ useDispatch()              │ → DISPATCH
│ useStore()                 │ → STORE ACCESS
└────────────────────────────┘

В повседневном коде компонентов первые два элемента выполняют практически всю работу:

90%+
useSelector()
useDispatch()

useStore() используется лишь эпизодически.

connect(): API высших порядков для компонентов

Хуками рекомендуется пользоваться, но connect() по-прежнему существует, и многие долговечные кодбазы построены на нем. Можно столкнуться с кодом вроде этого:

connect(
  mapStateToProps,
  mapDispatchToProps
)(Component)

Как connect преобразует данные хранилища в свойства

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

Redux Store
     │
     ▼
connect()
     │
     ├── mapStateToProps
     │
     └── mapDispatchToProps
     │
     ▼
Component Props

mapStateToProps принимает состояние и возвращает объект свойств:

const mapStateToProps = state => ({
  user: state.auth.user,
  count: state.counter.value
})

Фактически он выполняет следующее преобразование:

Redux State
     ↓
Component Props

Обернутый компонент остается обычной функцией, зависящей от своих свойств, и не знает о существовании Redux:

function User({ user, count }) {
  return (
    <div>
      {user.name}
      {count}
    </div>
  )
}

mapDispatchToProps предоставляет функции-обратные вызовы. В своей функциональной форме вы получаете dispatch и сами создаете обработчики:

const mapDispatchToProps =
  dispatch => ({
    increment: () =>
      dispatch(increment())
  })

Затем компонент вызывает их в качестве свойств:

props.increment()

Сокращенная форма mapDispatchToProps

Более краткая форма передает объект создателей действий:

const mapDispatchToProps = {
  increment,
  decrement
}

React-Redux привязывает каждого создателя действий так, что при вызове свойства происходит его выполнение. Обычно это более аккуратный вариант.

Четыре распространенных варианта подключения

Вы столкнетесь с четырьмя вариациями. При отсутствии аргументов компонент получает в качестве свойства только dispatch:

connect()(Component)

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

connect(
  mapStateToProps
)(Component)

При использовании null в качестве первого аргумента компонент никогда не слушает хранилище данных и принимает только параметры dispatch:

connect(
  null,
  mapDispatchToProps
)(Component)

А при использовании обоих аргументов он одновременно считывает данные и отправляет команды dispatch:

connect(
  mapStateToProps,
  mapDispatchToProps
)(Component)

Важно знать, что использование null позволяет пропустить процедуру подписки: это недорогой способ предоставить компоненту возможность отправки команд dispatch без его перерисовки при обновлении хранилища данных.

connect возвращает новый компонент

Вызов

connect(
  mapStateToProps,
  mapDispatchToProps
)(MyComponent)

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

MyComponent
     │
     ▼
connect()
     │
     ▼
ConnectedComponent

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

Выбор между хуками и connect

Для нового приложения ответ прост:

New React application
        ↓
Hooks

Для уже существующего решения ответ прост и прагматичен:

Existing connect()
        ↓
Understand and maintain it

Хуки позволяют сократить количество шаблонного кода, исключают необходимость в компонентах-обертках и значительно упрощают работу с TypeScript, поэтому они являются стандартом. Компоненты, использующие connect(), не требуют переписывания; их следует преобразовать при любом изменении.

Разница в проверке равенства за одну строку

Помните об этой паре:

useSelector()
      ↓
=== reference equality

против

connect()
      ↓
shallow equality

Многие ошибки вида «раньше всё работало до рефакторинга» возникают из-за этого: свежий объект был безопасен в mapStateToProps, но не проходит проверку === в useSelector().

Определение типов для React-Redux с использованием TypeScript

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

RootState
AppDispatch
AppStore
useAppSelector
useAppDispatch
useAppStore

RootState: определение типа состояния на основе хранилища

Если у вас есть магазин, настроенный с использованием Redux Toolkit,

const store = configureStore({
  reducer: {
    counter: counterReducer,
    users: usersReducer
  }
})

вы можете получить тип состояния из того, что возвращает getState:

export type RootState =
  ReturnType<typeof store.getState>

Это лучше, чем вручную определять структуру состояния,

type RootState = {
  counter: CounterState
  users: UsersState
}

потому что автоматически определённый тип автоматически учитывает редьюсеры.

AppDispatch: тип диспача, включая мидлвары

Тип диспача можно определить аналогичным образом:

export type AppDispatch =
  typeof store.dispatch

Простой тип Redux Dispatch поддерживает только простые действия; автоматически определённый тип учитывает наличие мидлваров, что важно при использовании:

  • мидлваров, изменяющих параметры, принимаемые dispatch
  • функций-танков
  • переопределённого диспача
  • асинхронных действий любого типа

Без AppDispatch вызов танка приведёт к ошибке типа.

AppStore: тип самого магазина

Тип магазина можно определить ещё одним шагом инференции:

export type AppStore =
  typeof store

Благодаря этому у каждой проблемы есть единый источник истины. Тип состояния:

RootState
    ↓
state type

Тип диспетчера:

AppDispatch
    ↓
dispatch type

Тип хранилища:

AppStore
    ↓
store type

AppStore особенно удобен, когда необходимо создавать хранилище для каждого запроса или теста и передавать его дальше.

Заранее определенные хуки с withTypes()

Начиная с React-Redux 9.1.0, каждый хук предоставляет метод .withTypes():

useDispatch.withTypes()
useSelector.withTypes()
useStore.withTypes()

Описанный паттерн позволяет один раз создать хуки, специфичные для конкретного приложения:

export const useAppDispatch =
  useDispatch.withTypes<AppDispatch>()

export const useAppSelector =
  useSelector.withTypes<RootState>()

export const useAppStore =
  useStore.withTypes<AppStore>()

Что дают типизированные хуки

Без них каждому селектору требуется явная аннотация:

const user = useSelector(
  (state: RootState) =>
    state.auth.user
)

С типизированным хуком,

const user = useAppSelector(
  state => state.auth.user
)

компилятор уже знает

state = RootState

Часть, отвечающая за диспетчеринг, работает так же:

const dispatch = useAppDispatch()

Этот метод dispatch принимает функции-обертки и всё остальное, что разрешает ваш middleware, с полной проверкой.

Файл hooks.ts для приложения

В типичном проекте всё это хранится в одном небольшом модуле:

import {
  useDispatch,
  useSelector,
  useStore
} from 'react-redux'

import type {
  RootState,
  AppDispatch,
  AppStore
} from './store'

export const useAppDispatch =
  useDispatch.withTypes<AppDispatch>()

export const useAppSelector =
  useSelector.withTypes<RootState>()

export const useAppStore =
  useStore.withTypes<AppStore>()

Компоненты импортируют данные из этого модуля вместо прямого импорта из react-redux:

const user = useAppSelector(
  state => state.auth.user
)

const dispatch = useAppDispatch()

ConnectedProps для типизированного кода connect()

Кодовые базы, в которых компоненты connect() типизированы, будут содержать

ConnectedProps

Разделите вызов connect сначала на создание коннектора, а затем извлеките свойства, которые он вводит:

const connector = connect(
  mapState,
  mapDispatch
)

type PropsFromRedux =
  ConnectedProps<typeof connector>

Функция PropsFromRedux точно описывает, что вводит коннектор, поэтому типы никогда не дублируются.

Проектирование хороших селекторов

Легко подумать, что селектор — это не более чем

state => state.user

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

Redux State
     ↓
Selector
     ↓
UI-friendly data

Например, правило отображения списка задач может находиться в отдельной функции с именем:

const selectVisibleTodos =
  state =>
    state.todos.filter(
      todo => !todo.hidden
    )

а компонент просто запрашивает результат её работы:

const todos = useSelector(
  selectVisibleTodos
)

Компонент остается представительским, а правило можно тестировать отдельно.

Хороший селектор — это точный

const selectUserName =
  state => state.auth.user.name

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

Выбор всего состояния почти всегда неверен

const selectEverything =
  state => state

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

Anything in Redux changes
        ↓
Root state reference changes
        ↓
Selector result changes
        ↓
Component re-renders

В режиме разработки React-Redux фиксирует такой паттерн.

Сохраняйте селекторы детализированными

Предпочитайте несколько целенаправленных запросов:

const count =
  useSelector(
    state => state.counter.value
  )

const user =
  useSelector(
    state => state.auth.currentUser
  )

вместо одного общего запроса:

const state =
  useSelector(state => state)

Полезное правило:

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

Проверки селекторов в режиме разработки

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

Проверка стабильности

В первой проверке селектор вызывается второй раз с тем же состоянием, и результаты сравниваются:

selector(state)
      ↓
run again with same state
      ↓
same result?

Если результат

same reference

селектор является стабильным. Если же нет

new reference

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

Типичным примером является селектор в виде объекта-литерала, описанный ранее:

const data = useSelector(
  state => ({
    count: state.count,
    user: state.user
  })
)

Поскольку объект пересоздается при каждом вызове, проверка выявляет:

same input
    ↓
different object
    ↓
unstable selector

Настройка частоты выполнения проверок

Вы можете установить частоту выполнения для всего приложения в компоненте Provider:

<Provider
  store={store}
  stabilityCheck="always"
>
  <App />
</Provider>

или переопределить её для отдельного вызова хука:

const count = useSelector(
  selectCount,
  {
    devModeChecks: {
      stabilityCheck: 'once'
    }
  }
)

Допустимые значения:

never
once
always

По умолчанию значение — 'once', что означает, что проверка выполняется при первом вызове каждого хука. Ничего из этого не выполняется в продакшн-версиях приложения.

Проверка функции идентичности

Вторая проверка ищет селектор, который возвращает свои входные данные без изменений:

state => state

В компоненте это выглядит так:

const state = useSelector(
  state => state
)

Это связывает компонент с каждым изменением хранилища данных:

Any Redux change
       ↓
Root state changes
       ↓
Component re-renders

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

Решение такое же, как и раньше: замените

const state = useSelector(
  state => state
)

на чтение конкретных значений, которые вам нужны:

const count = useSelector(
  state => state.counter.value
)

const user = useSelector(
  state => state.auth.currentUser
)

Отрисовка и производительность за пределами селекторов

Отрисовка родителя всё ещё каскадируется

useSelector() управляет только теми отрисовками, которые вызваны обновлениями хранилища. Он не влияет на обычное правило React, согласно которому компонент отрисовывается при отрисовке его родителя:

Parent renders
      ↓
Child renders

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

React.memo()

Это отличается от connect(), чей обертывающий компонент ведет себя как мемизированный элемент; компоненты, основанные на хуках, не получают такой функциональности бесплатно.

Сочетание React.memo с useSelector

Здесь компонент подписывается на счётчик и одновременно мемизируется по свойству name:

const Counter = ({ name }) => {
  const count = useSelector(
    state => state.counter.value
  )

return (
    <div>
      {name}: {count}
    </div>
  )
}
export default React.memo(Counter)

Эти два механизма покрывают два источника перерисовки:

Redux selector
       +
React.memo
       ↓
More controlled rendering

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

Модель производительности, которая помещается на один экран

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

Redux action
     ↓
Store updates
     ↓
Selectors execute
     ↓
Selector results compared
     ↓
Changed?
 ┌───┴────┐
No       Yes
│         │
│         ▼
│      Re-render
│
└── No Redux-triggered render

Ваша задача — написать селекторы, которые:

  1. возвращают только те данные, которые использует компонент
  2. возвращают стабильные ссылки, когда ничего значимого не изменилось
  3. не создают новые объекты или массивы без необходимости
  4. мемоизируют данные, вычисление которых требует много ресурсов

Правило первое: никогда не выбирать корневое состояние

useSelector(state => state)

Правило второе: избегать создания обёрточных объектов в селекторах

Подобный селектор создаёт новые объекты при каждом запуске:

useSelector(state => ({
  user: state.user,
  count: state.count
}))

Возвращайте такой объект только тогда, когда вы намеренно используете его вместе с

shallowEqual

или передаёте его через мемоизированный селектор.

Правило третье: мемоизировать ресурсоёмкие вычисления

Сортировка, фильтрация, группировка и объединение относятся к

createSelector(...)

Правило четвертое: используйте React.memo для нужных компонентов

Прежде чем мемоизировать компонент, пройдитесь по краткому списку проверок:

Is the component expensive?
       ↓
Does it receive stable props?
       ↓
Does it re-render unnecessarily?
       ↓
Then consider React.memo()

Если в вашей кодовой базе используется React Compiler, большая часть такого ручного мемоизирования уже обрабатывается автоматически.

Правило пятое: выбирайте наименьший возможный диапазон для селектора

Слишком широкий диапазон:

state => state

Лучше:

state => state.auth.user

Еще лучше, когда компонент отображает только название:

state => state.auth.user.name

Редкие крайние случаи: устаревшие параметры и «зомби-дети»

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

Устаревшие параметры

Для решения проблемы устаревших параметров необходим селектор, зависящий от конкретного параметра, а также обновление хранилища данных, изменяющее как состояние, так и этот параметр косвенно:

Selector depends on component props
        ↓
Redux action updates state
        ↓
Parent would receive new props
        ↓
Child selector runs first
        ↓
Selector sees old props

Подписка дочернего элемента может сработать раньше, чем родительский элемент перерисуется и передаст новые параметры. На короткое время селектор сочетает в себе актуальное состояние и старые параметры. Типичная ситуация выглядит так:

const todo = useSelector(
  state => state.todos[props.id]
)

Если элемент с указанным id только что был удален или родительский элемент собирается передать другой id, селектор временно читает данные, которые уже не соответствуют действительности.

Создание селекторов, устойчивых к отсутствию данных

Нестабильная версия предполагает, что элемент всегда существует:

state.todos[props.id].name

Защитная версия сначала пытается найти элемент,

const todo =
  state.todos[props.id]

а только затем читает из него данные:

return todo
  ? todo.name
  : undefined

Решение заключается в простом проверочном условии:

Does data exist?
      ↓
Yes → use it
No  → handle safely

Опциональное цепление (state.todos[id]?.name) выражает ту же идею в одном операторе.

«Зомби»-дочерние элементы

Сценарий с «зомби-ребёнком» включает родителя, отвечающего за отображение списка, и ребёнка, подписанного на один из элементов этого списка:

Parent
  │
  └── Child

Последовательность действий выглядит следующим образом:

  1. Ребёнок подписывается на хранилище данных.
  2. Какое-то действие удаляет данные, которые отображает ребёнок.
  3. При следующем отрисовывании родитель перестаёт отображать этого ребёнка.
  4. До того, как это произойдёт, срабатывает подписка ребёнка.
  5. Селектор ребёнка пытается получить данные, которых уже нет.

На этом последнем шаге возникает ошибка из-за незащищённого селектора. React-Redux имеет механизмы для обработки ошибок селекторов, вызванных обновлениями хранилища, но защитные селекторы остаются наиболее надёжным решением.

Почему хуки более уязвимы, чем connect

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

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

Расширенные функции Provider

Персонализированный контекст для изолированных хранилищ

По умолчанию,

<Provider store={store}>

хранилище публикуется через встроенный контекст React-Redux. Библиотека компонентов, которая использует Redux внутри себя, может столкнуться с конфликтом с хранилищем основного приложения. Чтобы избежать этого, Provider принимает собственный контекст:

<Provider
  context={MyContext}
  store={myStore}
>

Хуки, привязанные к этому контексту, поступают из функций-фабрик:

createStoreHook()
createDispatchHook()
createSelectorHook()

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

batch() и автоматическое группирование операций в React 18

В старом коде часто группируют последовательные вызовы dispatch() с помощью batch(), чтобы React отрисовывал интерфейс один раз вместо двух:

batch(() => {
  dispatch(action1())
  dispatch(action2())
})

В React 18 обновления автоматически группируются, включая те, которые происходят в рамках обещаний и таймеров, поэтому типичному приложению React 18 не требуется использование batch() в этих случаях. В новых версиях React-Redux оно сохраняется в основном ради совместимости; проверьте актуальную документацию и ожидайте его наличия в старом коде.

serverState для серверной отрисовки и гидратации

Для серверной отрисовки компонент Provider принимает дополнительное свойство:

<Provider
  store={store}
  serverState={preloadedState}
>

Сервер генерирует HTML из начального состояния и отправляет это состояние браузеру. Параметр serverState обеспечивает использование того же снимка состояния при процессе гидратации, что предотвращает несоответствия:

Server
  ↓
Initial Redux State
  ↓
HTML
  ↓
Browser Hydration
  ↓
Provider(serverState)
  ↓
Consistent initial render

Типичная структура проекта

Приложения на TypeScript, построенные с использованием Redux Toolkit, часто организованы по функциональным блокам, причем настройки хранилища состояния сосредоточены в одном месте:

src/
│
├── app/
│   ├── store.ts
│   └── hooks.ts
│
├── features/
│   │
│   ├── counter/
│   │   ├── counterSlice.ts
│   │   └── Counter.tsx
│   │
│   ├── users/
│   │   ├── usersSlice.ts
│   │   └── Users.tsx
│   │
│   └── auth/
│       ├── authSlice.ts
│       └── Login.tsx
│
├── App.tsx
└── main.tsx

store.ts

Модуль хранилища состояния настраивает редьюсеры и экспортирует соответствующие типы:

const store = configureStore({
  reducer: {
    counter: counterReducer,
    users: usersReducer,
    auth: authReducer
  }
})

export type RootState =
  ReturnType<typeof store.getState>

export type AppDispatch =
  typeof store.dispatch

export type AppStore =
  typeof store

hooks.ts

Модуль хуков преобразует эти типы в хуки приложения:

export const useAppDispatch =
  useDispatch.withTypes<AppDispatch>()

export const useAppSelector =
  useSelector.withTypes<RootState>()

export const useAppStore =
  useStore.withTypes<AppStore>()

Компонент, использующий оба подхода

Компонент-счётчик работает, читая и записывая данные исключительно через типизированные хуки:

function Counter() {
  const count = useAppSelector(
    state => state.counter.value
  )

const dispatch = useAppDispatch()
  return (
    <>
      <span>{count}</span>
      <button
        onClick={() =>
          dispatch(increment())
        }
      >
        +
      </button>
    </>
  )
}

Поток выполнения в этом компоненте полностью соответствует основному циклу с самого начала:

Component
    │
    ├── useAppSelector()
    │        ↓
    │      READ
    │
    └── useAppDispatch()
             ↓
          DISPATCH
             ↓
           Redux
             ↓
          New State
             ↓
       useAppSelector()
             ↓
          Component

Роль Redux Toolkit

Redux Toolkit и React-Redux дополняют друг друга, а не являются альтернативами:

Redux Toolkit
      +
React-Redux

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

configureStore
createSlice
createAsyncThunk
createSelector
RTK Query

React-Redux продолжает отвечать за связь с React:

Provider
useSelector
useDispatch
useStore
connect

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

Полная схема в одной диаграмме

Собираем все компоненты воедино, начиная с элемента Provider и заканчивая проверкой равенства:

React
                   │
                   ▼
             <Provider>
                   │
                   ▼
             Redux Store
                   │
          ┌────────┴────────┐
          │                 │
    useSelector()      useDispatch()
          │                 │
          │                 ▼
          │               Action
          │                 │
          │                 ▼
          │              Reducer
          │                 │
          │                 ▼
          │             New State
          │                 │
          └─────────┬───────┘
                    ▼
              Selector runs
                    │
                    ▼
             Equality check
                    │
             ┌──────┴──────┐
             │             │
           Same         Different
             │             │
             ▼             ▼
         No Redux       Re-render
          render

Распространенные ошибки и способы их выявления

Подписка на весь состояние

useSelector(state => state)

Замените её на конкретные селекторы.

Заключение значений в новый объект

useSelector(state => ({
  user: state.user
}))

Разделите это на отдельные хуки или намеренно используйте функцию shallowEqual.

Фильтрация или преобразование внутри селектора при каждом запуске

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

Если это выполняется часто или с большими списками, переместите его в мемизированный селектор.

Чтение данных рендеринга через useStore

Вызов

store.getState()

в логике рендеринга позволяет получить значение без подписки. Используйте

useSelector()

чтобы компонент обновлялся при изменении значения.

Прямое изменение состояния

Приписка вроде

state.user.name = 'John'

нарушает принцип неизменяемости обновлений в Redux: ссылки не меняются, поэтому селекторы видят «никаких изменений», и компоненты не рендерятся. (В редьюсерах из функции createSlice Redux Toolkit такой подход допускается, поскольку Immer преобразует его в неизменяемое обновление; во всех остальных случаях это ошибка.)

Побочные эффекты внутри селекторов

state => {
  fetch(...)
  return state.user
}

Селектор должен вычислять и возвращать только результат, ничего больше.

Запоминание по привычке

Распространение этого подхода повсюду

useMemo()
useCallback()
React.memo()

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

Неправильное размещение экземпляров селекторов, сохраненных в кэше

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

global
per component
per component instance

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

Вопросы на собеседовании с краткими ответами

Что такое React-Redux и зачем нужен Provider?

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

useSelector против useDispatch?

Один из них используется для чтения:

useSelector
    ↓
READ Redux state

Другой вариант написания:

useDispatch
    ↓
DISPATCH Redux actions

Как useSelector запускает перерисовку?

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

Почему этот выборник постоянно запускает перерисовку, и как это исправить?

useSelector(state => ({
  user: state.user,
  count: state.count
}))

На каждой итерации создается новый объект, поэтому проверка ссылок всегда проваливается. Три способа решения:

1. Multiple useSelector calls

2. shallowEqual

3. Memoized selector

useSelector против connect?

Хуки сравнивают результаты выборников по ссылкам; connect() выполняет поверхностное сравнение свойств из mapStateToProps. Хуки являются стандартом, connect() по-прежнему поддерживается.

Зачем использовать мемоизированные выборники?

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

todos
  ↓
filter completed
  ↓
new array

Что такое RootState, AppDispatch и withTypes()?

Выведенный тип всего состояния:

type RootState =
  ReturnType<typeof store.getState>

Выведенный тип диспетчера, включая промежуточные компоненты вроде thunks:

type AppDispatch =
  typeof store.dispatch

А также вспомогательные функции, которые возвращают хуки, заранее привязанные к этим типам:

useDispatch.withTypes<AppDispatch>()

useSelector.withTypes<RootState>()

useStore.withTypes<AppStore>()

Зачем нужны типизированные хуки?

Они заменяют повторяющиеся аннотации вроде

useSelector(
  (state: RootState) =>
    state.user
)

на

useAppSelector(
  state => state.user
)

Почему селекторы должны быть чистыми?

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

Для чего нужен useStore?

Редкие случаи, когда действительно требуется объект хранилища. Чтение состояния для отрисовки происходит через useSelector().

Что такое «зомби-дети» и устаревшие атрибуты?

Оба явления представляют собой редкие проблемы с порядком обработки. «Зомби-дети» продолжают обрабатывать обновления до того, как их родитель удаляет их, и читают удаленные данные; устаревшие атрибуты означают, что селектор, зависящий от атрибутов, выполняется с актуальным состоянием, но со старыми атрибутами. Защитные селекторы позволяют решить обе проблемы.

Требуется ли всё ещё функция batch() в React 18?

Как правило, нет, поскольку React 18 автоматически группирует операции, но вы всё ещё можете столкнуться с ней в более старом коде.

Почему функции connect и хуки могут отрисовывать контент по-разному?

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

connect()
    ↓
shallow equality

против

useSelector()
    ↓
strict === equality

Почему функция useSelector выполняется так часто?

Она:

  1. выполняется во время отрисовки
  2. слушает изменения в хранилище
  • перезапускается после каждого выполненного действия
  • использует сохранённый результат во время отрисовки только тогда, когда функция-селектор и состояние не изменились
  • Таким образом, встроенный селектор, являясь новой функцией при каждой отрисовке, выполняется при каждой отрисовке; стабильная ссылка на селектор позволяет React-Redux пропустить это вызов.

    Что изучать в порядке приоритета

    Уровень один: обязательно должно быть знакомо

    Provider
    useSelector
    useDispatch
    Redux Store flow
    Selectors
    === equality
    Re-render behavior
    Redux Toolkit + React-Redux
    TypeScript
    RootState
    AppDispatch
    .withTypes()
    

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

    shallowEqual
    Memoized selectors
    createSelector
    useStore
    connect
    mapStateToProps
    mapDispatchToProps
    ConnectedProps
    React.memo
    

    Уровень три: продвинутые темы

    Stale props
    Zombie children
    Selector + props
    Selector memoization
    Custom context
    Development mode checks
    SSR serverState
    batch()
    

    Что не стоит запоминать

    Вам не нужно изучать документацию строчка за строчкой. Можно пропустить:

    • способ внутренней реализации механизма подписки
    • редко используемые опции connect()
    • давно устаревшие паттерны
    • детали реализации исходного кода
  • точный текст каждого предупреждения о проблемах в разработке
  • каждое сложное свойство Provider
  • Важен логический подход к правилам. Знание фактов

    useSelector uses ===
    

    менее полезно, чем способность отвечать на вопросы

    Why?
    

    а именно:

    Because returning a new object
    creates a new reference.
    

    это приводит к определенной цепочке действий

    New reference
         ↓
    === false
         ↓
    re-render
    

    Если вы понимаете эту цепочку, вы сможете сами вывести большинство остальных правил.

    Десять правил, которые следует соблюдать

    1. Чтение данных с использованием useSelector

    useSelector()
    

    это способ, с помощью которого компоненты читают состояние.

    2. Запись данных с использованием useDispatch

    useDispatch()
    

    это способ, с помощью которого компоненты отправляют действия.

    3. Оберните приложение в Provider

    <Provider store={store}>
    

    4. Помните правила сравнения

    useSelector → ===
    connect → shallow comparison
    

    5. Никогда не выбирайте корневое состояние

    useSelector(state => state)
    

    6. Будьте осторожны с выбрасывающими объекты функциями выбора

    useSelector(state => ({
      ...
    }))
    

    7. Хранение в кэше дорогостоящих вычисляемых данных

    Memoized selector
    

    8. Соблюдение правил формирования селекторов

    Pure
    Predictable
    Granular
    

    9. Сначала определите типы хранилища, затем хуки

    Сначала автоматически определенные типы:

    RootState
    AppDispatch
    AppStore
    

    затем хуки приложения, созданные на их основе:

    useAppSelector
    useAppDispatch
    useAppStore
    

    10. Используйте современные практики парного использования

    Redux Toolkit
           +
    React-Redux Hooks
    

    Вся API на одной странице

    Компактный справочник, охватывающий основные хуки, рекомендации по производительности, типы TypeScript, устаревшую API и продвинутые темы:

    ┌───────────────────────────────────────────────┐
    │               REACT-REDUX                     │
    ├───────────────────────────────────────────────┤
    │                                               │
    │ <Provider store={store}>                      │
    │        ↓                                      │
    │ Makes Redux available to React                │
    │                                               │
    │ useSelector()                                 │
    │        ↓                                      │
    │ READ state                                    │
    │        ↓                                      │
    │ Default comparison: ===                       │
    │                                               │
    │ useDispatch()                                 │
    │        ↓                                      │
    │ DISPATCH actions                              │
    │                                               │
    │ useStore()                                    │
    │        ↓                                      │
    │ Direct store access                           │
    │        ↓                                      │
    │ Use rarely                                    │
    │                                               │
    ├───────────────────────────────────────────────┤
    │ PERFORMANCE                                   │
    ├───────────────────────────────────────────────┤
    │                                               │
    │ Avoid state => state                          │
    │ Avoid unnecessary object creation             │
    │ Use granular selectors                        │
    │ Use shallowEqual when appropriate             │
    │ Use memoized selectors for derived data       │
    │ Use React.memo when justified                 │
    │                                               │
    ├───────────────────────────────────────────────┤
    │ TYPESCRIPT                                    │
    ├───────────────────────────────────────────────┤
    │                                               │
    │ RootState = ReturnType<typeof store.getState> │
    │ AppDispatch = typeof store.dispatch           │
    │ AppStore = typeof store                       │
    │                                               │
    │ useAppSelector                                │
    │ useAppDispatch                                │
    │ useAppStore                                   │
    │                                               │
    ├───────────────────────────────────────────────┤
    │ LEGACY / EXISTING APPLICATIONS                │
    ├───────────────────────────────────────────────┤
    │                                               │
    │ connect()                                     │
    │ mapStateToProps                               │
    │ mapDispatchToProps                            │
    │ ConnectedProps                                │
    │                                               │
    ├───────────────────────────────────────────────┤
    │ ADVANCED                                      │
    ├───────────────────────────────────────────────┤
    │                                               │
    │ Stale Props                                   │
    │ Zombie Children                               │
    │ Custom Context                                │
    │ SSR serverState                               │
    │ Development checks                            │
    │ batch()                                       │
    │                                               │
    └───────────────────────────────────────────────┘
    

    А также цикл, изображенный снова с путями чтения и записи рядом друг с другом:

                            REACT
                               │
                               │
                        <Provider />
                               │
                               ▼
                        ┌─────────────┐
                        │ Redux Store │
                        └──────┬──────┘
                               │
                   ┌───────────┴───────────┐
                   │                       │
                   ▼                       ▲
            useSelector()             useDispatch()
                   │                       │
                   │                       │
                 READ                    ACTION
                   │                       │
                   │                       │
                   │                  ┌────┴─────┐
                   │                  │ Reducer  │
                   │                  └────┬─────┘
                   │                       │
                   │                       ▼
                   │                  New State
                   │                       │
                   └───────────────────────┘
                               │
                               ▼
                        Equality Check
                               │
                        ┌──────┴──────┐
                        │             │
                      Same         Changed
                        │             │
                        ▼             ▼
                    No Redux      Re-render
                     render
    

    Для нового проекта на TypeScript рекомендуемая конфигурация выглядит примерно так:

                Redux Toolkit
                       +
                  React-Redux
                       │
            ┌──────────┴──────────┐
            │                     │
         Store                 Provider
            │                     │
            │               Application tree
            │                     │
            └──────────┬──────────┘
                       │
              ┌────────┴────────┐
              │                 │
       useAppSelector()   useAppDispatch()
              │                 │
            READ              WRITE
              │                 │
              └────────┬────────┘
                       │
                    Redux
                       │
                  New State
                       │
                    Selector
                       │
                    Re-render
    

    Заключение

    React-Redux становится гораздо проще в использовании, когда вы перестаете рассматривать его экспорты как не связанные между собой инструменты. Этот список названий

    Provider
    useSelector
    useDispatch
    connect
    shallowEqual
    createSelector
    useStore
    

    Описывает одну пайплайн-структуру с одной задачей на каждом этапе. Провайдер предоставляет хранилище данных:

    Provider
       ↓
    makes Store available
    

    Хук выбора считывает данные:

    useSelector
       ↓
    reads selected state
    

    Хук диспетчеризации отправляет команды:

    useDispatch
       ↓
    sends actions
    

    Редьюсеры генерируют следующее состояние:

    Reducer
       ↓
    creates new state
    

    Селекторы формируют состояние для отображения в интерфейсе:

    Selector
       ↓
    derives data
    

    Проверка на равенство определяет, изменились ли какие-либо важные данные:

    Equality
       ↓
    decides whether selected data changed
    

    А React отвечает за отрисовку:

    React
       ↓
    re-renders when necessary
    

    В TypeScript цепочка операций также имеет линейную структуру:

    Store
     ↓
    RootState
    AppDispatch
    AppStore
     ↓
    .withTypes()
     ↓
    useAppSelector()
    useAppDispatch()
    useAppStore()
    

    Когда компонент отрисовывает больше данных, чем следует, необходимо каждый раз рассматривать те же четыре вопроса:

    useSelector()
          ↓
    What does my selector return?
          ↓
    Is the reference stable?
          ↓
    Does the selected value actually change?
          ↓
    Should this component re-render?
    

    Основные выводы:

    • Большинство проблем с производительностью в React-Redux связаны со селекторами, которые возвращают новые ссылки, а не с самим Redux.
  • useSelector() сравнивает значения с помощью оператора ===; connect() выполняет поверхностное сравнение. Перенос кода между ними без корректировки селекторов приводит к изменению поведения.
  • Используйте узкие селекторы, храните результаты вычислений в памяти с помощью экземпляров createSelector на уровне модуля, и применяйте shallowEqual только тогда, когда необходим результат в виде объекта.
  • Определяйте структуру RootState, AppDispatch и AppStore на основе хранилища данных и создавайте типизированные хуки с использованием метода .withTypes().
  • Понимание таких концепций, как устаревшие пропсы, «зомби-дети», пользовательские контексты, функция batch() и параметр serverState, важно, но это крайние случаи, а не повседневные проблемы.
  • В качестве самопроверки попробуйте объяснить из памяти каждый раздел этого руководства — от Provider и принципа равенства, через типизированные хуки до serverState — и написать базовую настройку, не обращаясь ни к чему в поиске.

    Для получения подробной информации официальными источниками являются API хуков, API Provider, Руководство по использованию с TypeScript, Быстрое начало и Документация к функции connect(). Сначала изучите основной цикл и принцип равенства, затем селекторы, TypeScript, вопросы производительности, connect и крайние случаи, чтобы у вас была основа для понимания деталей API.

    Связанная литература

  • Prop Drilling Is Not a Reason to Install Redux or Zustand — Проверка четырех распространенных причин добавления библиотек управления состоянием в рабочий код React: использование Context для передачи свойств, функции useState, функция useSyncExternalStore и затраты на перерисовку, связанные с Context.
  • React's Scheduler and the Event Loop: Who Really Decides When Work Runs — Рассмотрим, как кооперативный планировщик React работает внутри цикла событий JavaScript, почему происходят переходы состояния и почему ни один планировщик не может спасти заблокированную нить выполнения.
  • Размышления о React Hooks через снимки отрисовки и компромиссы — продвинутая модель мышления для React и React Native Hooks: снимки отрисовки, эффекты, ссылки, мемоизация, пользовательские Hooks и способы объяснения компромиссов на собеседованиях.