Моя идеальная архитектура Redux
30 января 2020 г.
Я часто вижу, как начинающие и даже опытные редаксеры допускают ошибки в архитектуре, обрекая приложение на неизбежное погружение в пучину горя, боли и разрушения.
К тому, что перечислено в этом посте, я пришел за несколько лет использования Redux, и надеюсь эти практики помогут вам разорвать порочный круг насилия.
Итак, в чем же суть?
Будем рассматривать все на примере самого обычного “голого” редакса, без @reduxjs/toolkit и подобных штук.
Ничто не помешает вам адаптировать этот подход и под другие инструменты.
Основная единица архитектуры - это модуль. Они могут быть глобальными и локальными.
Глобальные находятся рядом со стором, так как они не закреплены за определенной фичей.
Данные о юзере и авторизации, состояние общих для приложения модалок и все такое - это глобальные модули.
Локальные модули всегда закреплены за определенной фичей и находятся внутри ее.
К локальным модулям можно отнести список пользователей, заметки, задачи.
Модуль имеет следующую структуру:
counter
 ┣ actions.js
 ┣ index.js
 ┣ reducer.js
 ┣ selectors.js
 ┗ types.jsПо очереди:
types.js
Здесь в виде констант находятся типы экшнов.
export const INCREASE = 'counter/INCREASE'
export const DECREASE = 'counter/DECREASE'
export const SET_COUNT = 'counter/SET_COUNT'
export const RESET = 'counter/RESET'reducer.js
Не трудно догадаться, что тут обитает редьюсер.
import * as types from './types'
const initialState = 0
export function reducer(state = initialState, action) {
  const { payload } = action
  switch(action.type) {
    case types.INCREASE:
      return state + 1
    case types.DECREASE:
      return state - 1
    case types.SET_COUNT:
      return payload
    case types.RESET:
      return initialState
    default:
      return state
  }
}actions.js
Здесь живут наши экшны (если точнее, action creators).
import * as types from './types.js'
export const increase = () => ({ type: types.INCREASE })
export const decrease = () => ({ type: types.DECREASE })
export const setCount = count => ({
  type: types.SET_COUNT,
  payload: count
})
export const reset = () => ({ type: types.RESET })selectors.js
Селекторы, которым, кстати, посвящена отдельная статья.
export const everything = state => state.counter
export const count = state => everything(state)index.js
Здесь начинается самое интересное.
В этом файле модуль объявляет свое внешнее api.
Данная архитектура интересна тем, что селекторы и экшны “упаковываются” в объекты, и в таком виде их становится очень удобно использовать снаружи.
Благодаря этому мы не засоряем файлы кучей импортов, а просто импортируем группы селекторов/экшнов определенного модуля.
Также это позволяет давать селекторам понятные имена и избегать конфликтов с другими модулями, а экшны передавать внутрь компонента сразу группой, без перечисления.
import { reducer } from './reducer'
import * as selectors from './selectors'
import * as actions from './actions'
export {
  reducer as counterReducer,
  selectors as counterSelectors,
  actions as counterActions
}Важная заметка:
Иногда в экшнах нужно использовать селекторы из этого же модуля.
В таком случае их нужно импортировать как и типы экшнов, через import * as selectors.
Иначе высок риск столкнуться с циклической зависимостью.
Вид снаружи
Теперь нам нужно подключить модуль к компоненту.
Раньше для этого всегда использовались mapStateToProps и mapDispatchToProps, но теперь, с появлением хуков, все делается намного проще и элегантнее. Но помимо базовых useSelector и useDispatch нам понадобится магический хук useActions. Магическим он является потому, что позволяет невероятно легко получить заbindженные экшны в компоненте, избавляя нас от необходимости оборачивать их в dispatch руками.
useActions был удален из react-redux, так как великий Дэн Абрамов был против такого подхода.
Но к счастью, слова Дэна нас не касаются, так как мы будем использовать этот хук разумно.
Вот так он выглядит:
import { useDispatch } from 'react-redux'
import { useMemo } from 'react'
import { bindActionCreators } from 'redux'
export function useActions(actions) {
  const dispatch = useDispatch()
  const boundActions = useMemo(() => {
    return bindActionCreators(actions, dispatch)
  }, [])
  return boundActions
}А теперь используем все это в компоненте:
export const Counter = () => {
  const count = useSelector(counterSelectors.count)
  const { increment, decrement, reset } = useActions(counterActions)
  return (
    <div>
      <p>Count: {count}</p>
      <div>
        <button onClick={increment}>Increment</button>
        <button onClick={decrement}>Decrement</button>
        <button onClick={reset}>Reset</button>
      </div>
    </div>
  )
}Единственная проблема возникает тогда, когда нам нужны экшны с одинаковыми именами из разных модулей.
Ситуация редкая, но случиться может.
Чаще всего будет достаточно просто положить значение в другую переменную при деструктуризации:
const { reset: resetUsers } = useActions(usersActions)
const { reset: resetBlacklist } = useActions(blacklistActions)Пример структуры приложения
Сделано по мотивам feature slices.
features - наборы функциональности, призванные решать конкретные бизнес-сценарии, в нашем случае это управление пользователями и файлами.
ui - чистые реюзабельные компоненты, являющиеся UI-библиотекой данного проекта.
ui/templates - шаблоны страниц проекта. В данном случае есть один общий шаблон, использующийся всеми страницами. Если понадобится придать странице индивидуальности, можно создать отдельный шаблон.
pages - структура, содержащая иерархию страниц в проекте, может иметь вложенность. index.js каждой страницы содержит компонент, который рендерит фичу или набор фич, обернутые в template. Также там происходит инициализация данных, которые нужны конкретной странице, и выполняется логика, принадлежащая к странице в целом.
lib - общая логика, которую нельзя отнести к какой-то конкретной фиче (да, фичи могут иметь свою lib внутри). Все содержимое стоит группировать по папкам, название которых отображает предназначение. Папки наподобие helpers или util - плохая идея, так как они быстро превращаются в мусорку.
Так или иначе, даже в пределах feature slices могут быть индивидуальные для проекта архитектурные решения. То есть в некоторых случаях можно отходить от описанного выше (допустим, иногда фичи могут содержать шаблоны).
src
 ┣ features
 ┃ ┣ files
 ┃ ┃ ┣ modules
 ┃ ┃ ┃ ┗ files
 ┃ ┃ ┃ ┃ ┣ actions.js
 ┃ ┃ ┃ ┃ ┣ index.js
 ┃ ┃ ┃ ┃ ┣ reducer.js
 ┃ ┃ ┃ ┃ ┣ selectors.js
 ┃ ┃ ┃ ┃ ┗ types.js
 ┃ ┃ ┣ Files.js
 ┃ ┃ ┗ index.js
 ┃ ┗ users
 ┃ ┃ ┣ modules
 ┃ ┃ ┃ ┗ users
 ┃ ┃ ┃ ┃ ┣ actions.js
 ┃ ┃ ┃ ┃ ┣ index.js
 ┃ ┃ ┃ ┃ ┣ reducer.js
 ┃ ┃ ┃ ┃ ┣ selectors.js
 ┃ ┃ ┃ ┃ ┗ types.js
 ┃ ┃ ┣ Users.js
 ┃ ┃ ┗ index.js
 ┣ pages
 ┃ ┣ files
 ┃ ┃ ┗ index.js
 ┃ ┣ users
 ┃ ┃ ┗ index.js
 ┃ ┗ main
 ┃ ┃ ┗ index.js
 ┣ lib
 ┃ ┗ store
 ┃ ┃ ┣ modules
 ┃ ┃ ┃ ┣ global-modals
 ┃ ┃ ┃ ┃ ┣ actions.js
 ┃ ┃ ┃ ┃ ┣ index.js
 ┃ ┃ ┃ ┃ ┣ reducer.js
 ┃ ┃ ┃ ┃ ┣ selectors.js
 ┃ ┃ ┃ ┃ ┗ types.js
 ┃ ┃ ┃ ┗ session
 ┃ ┃ ┃ ┃ ┣ actions.js
 ┃ ┃ ┃ ┃ ┣ index.js
 ┃ ┃ ┃ ┃ ┣ reducer.js
 ┃ ┃ ┃ ┃ ┣ selectors.js
 ┃ ┃ ┃ ┃ ┗ types.js
 ┃ ┃ ┣ store.js
 ┃ ┃ ┣ root-reducer.js
 ┃ ┃ ┗ index.js
 ┣ ui
 ┃ ┗ templates
 ┃ ┃ ┗ common.js
 ┣ App.js
 ┗ index.jsНебольшие итоги
Как мы видим, даже такой монстр как редакс может быть вполне юзабелен.
Тем не менее, рекомендую ознакомиться с более современными и удобными стейт-менеджерами, такими как Effector и Reatom.