Risen.dev

Моя идеальная архитектура 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.