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