Возможно, вы сталкивались с рядом статей, в которых говорилось, что «Redux устарел из-за контекста React».

Реальность такова, что react-redux использует и всегда использовал контекстный API React в его различных формах с момента его дебюта. «Создать собственный Redux» — это то, что было возможно всегда — просто не нужно изобретать велосипед.

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

Между тем, нет официально поддерживаемого способа предотвратить использование компонентами любого фрагмента значения контекста React без повторного рендеринга.

Базовую функциональность Redux можно довольно легко реализовать заново — в конце концов, вся кодовая база довольно мала. Но нет смысла реализовывать собственный промежуточный API, redux-saga, redux-thunk, DevTools, useSelector и т. д.

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

Давайте использовать reselect в качестве примера. Reselect — это библиотека для запоминания результатов нормализации состояния вашего магазина.

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

const selectUsersList = createSelector(
  state => state.users.order.map(id => state.users.byId[id])
)
const useUsersList = () => useSelector(selectUsersList)

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

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

const selectUsersList = createSelector(
  [state => state.users.order, state => state.users.byId],
  (order, byId) => order.map(id => byId[id])
)

Этот селектор возвращает кэшированный массив результатов до тех пор, пока не изменятся объекты order или byId.

Распространенная проблема, возникающая при повторном выборе, — это необходимость передать свойства компонента в селектор, а также состояние. Например, такому компоненту, как <Todo id={todoId} />, может потребоваться передать свойство id селектору, чтобы получить правильное задание из состояния.

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

const createSelectTodo = () => createSelector(
  [state => state.todos.byId, (state, id) => id],
  (byId, id) => byId[id]
)
const useSelectTodo = id => {
  const selectTodo = useMemo(createSelectTodo)
  return useSelector(state => selectTodo(state, id))
}

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

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

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

const todoContext = createContext()
const TodoProvider = ({ children, id }) => {
  const selectTodo = useMemo(createSelectTodo)
  const todo = useSelector(
    useCallback(state => selectTodo(state, id), [id])
  )
  return (
    <todoContext.Provider value={todo}>
      {children}
    </todoContext.Provider>
  )
}
const useTodo = () => useContext(todoContext)
<TodoSelectorProvider id={todoId}>
  <Todo />
</TodoSelectorProvider>

Теперь все дочерние элементы компонента Todo могут получить текущую задачу, за рендеринг которой они отвечают, с помощью useTodo(). 👏

При таком гибридном подходе:

  • Мы избегали опорного бурения. Нам не нужно передавать объект todo всем потомкам компонента Todo, а также нам не нужно передавать его идентификатор.
  • Для каждого поставщика контекста будет создан экземпляр только одного селектора. Все еще могут быть дублированные селекторы, если нескольким провайдерам присвоен один и тот же идентификатор, но это маловероятно и обычно может быть решено путем поднятия нашего провайдера выше в иерархии компонентов.
  • Наши компоненты не будут повторно отображаться до тех пор, пока не изменятся соответствующие фрагменты состояния.

Здесь есть большой потенциал для совместного использования Redux и контекста. Ваше значение поставщика контекста может предлагать полный API, собственный (предпочтительно запомненный) объект методов для выбора данных хранилища и диспетчеризации создателей действий.

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

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

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

По большей части вывод, к которому я пришел, заключается в том, что Redux — отличный кандидат на действительно глобальное состояние — в частности, сущности, которые имеют серверное представление и требуют единого актуального источника правды. Контекст, с другой стороны, подходит для случаев использования, в которых состояние компонента очень хорошо относительно его родителя. Они не противоречат друг другу; вместо этого они могут дополнять друг друга.