Redux: Реанимируем легаси проект
Всем привет.
Немного контекста. У нас есть легаси проект, который пишется уже на протяжении порядка пяти лет. Когда мы его стартовали, было принято решение использовать redux в качестве стэйт менеджера. Сейчас не вижу смысла рассуждать на тему того, было ли это решение правильным, имеем то, что имеем, а именно кучу кода, мигрировать который на что-то иное вряд ли получится за адекватное время одновременно с написанием новых фич. А в чем проблема, спросите вы, redux прекрасный инструмент, зачем от него отказываться? Проблема в том, что философия глобальности redux побудила команду писать код, который постепенно превратился в неподдерживаемое нечто. Вообще, конечно, странная штука — глобальные переменные испокон веков считались анти паттерном, но redux, который по сути является глобальным объектом, обрел такую популярность и повсеместное использование. Но это так, мысли вслух.
Вторая проблема redux, которую мы ощутили на себе — он из коробки плохо переиспользуется. Возможно, это следствие его глобальной природы.
Давайте попробуем на простых примерах понять, что эти две озвученные проблемы означают для нас и что-то сделать, для того, чтобы решить их по возможности минимальными усилиями, без полного переписывания всего и вся.
Заглянем в код нашего легаси приложения
const slices = {
slice1: slice1Reducer,
slice2: slice2Reducer,
slice3: slice3Reducer,
slice4: slice4Reducer,
slice5: slice5Reducer,
slice6: slice6Reducer,
slice7: slice7Reducer,
slice8: slice8Reducer,
slice9: slice9Reducer,
slice10: slice10Reducer,
…
};
…
const combinedReducer = combineReducers(slices);
const store = createStore(combinedReducer);
Мне всегда этот код доставлял сильную боль. Каждый раз, как мы добавляем в продукт новую фичу, с новым редьюсером, нам нужно идти в этот глобальный список редьюсеров и прописывать его. Новая фича становится чуть более размазанной.
А было бы круто вообще не иметь этот глобальный список редьюсеров вовсе, а при добавлении новой фичи в продукт каким-то образом динамически добавлять ее редьюсер в систему. Идея не нова, уже есть ряд библиотек, которые решают подобную проблему. Но несложно раскрутить свое решение — в дальнейшем оно может чуть менее болезненно поддаваться кастомизации. Далее увидим, какой именно. Так мы и сделали. И у нас получилась библиотека под названием redux-attachable-reducer.
С вашего разрешения, позволю себе пару слов о том, как пользоваться этой библиотекой и как она устроена.
Когда мы пилим новую фичу, у которой есть свой редьюсер, не нужно идти его регистрировать в глобальном списке редьюсеров, достаточно сделать так (путь, куда аттачить редьюсер, задается в виде строки):
import { attachReducer } from "redux-attachable-reducer";
const Component = props => {
...
}
export default attachReducer({"path.to.store.key": reducer})( Component)
или так (путь, куда аттачить редьюсер, задается в виде объекта):
import { attachReducer } from "redux-attachable-reducer";
const Component = props => {
...
}
export default attachReducer({path: { to: { store: { key: reducer }} })( Component)
или в виде хука:
const Component = () => {
useAttachReducer(({"path.to.store.key": reducer})
…
}
Как оно работает? Представим себе, что мы динамически аттачим несколько редьюсеров:
{"one": r1, "one.two": r2, "one.three": r3}
Внутри себя библиотека строит дерево, наподобие того, что показано на картинке ниже:

Затем мы обходим получившееся дерево, чтобы создать общий композитный редьюсер:
const reducer = combineReducers(
{
one: reduceReducers(
r1,
combineReducers(
{
two: r2,
three: r3
}
)
)
})
Его мы комбинируем со статическими редьюсерами (теми редьюсерами, которые мы передаем в качестве первого аргумента в createStore).
Итоговый скомбинированный редьюсер затем передается в функцию replaceReducer, которая есть у redux-стора. (https://redux.js.org/api/store)
Все. Ушли длинные списки редьюсеров, которые было необходимо комбинировать друг с другом. Redux стал чуть более модульным, каждая фича приложения — чуть более изолированной.
Теперь нам бы добавить чуть большей реюзабельности нашему любимому redux.
Давайте на простом примере рассмотрим, как нам может в этом помочь redux-attachable-reducer и всем известный паттерн проектирования под названием фабрика.
Классический пример — компонент инкрементирования счетчика с redux в качестве state менеджера. Понятно, что вы можете сказать, зачем тут вообще state менеджер, и будете правы.
const Counter = () => {
const [value, setValue] = useState(0);
const handleClick = () => {
setValue(v => v + 1);
};
return (
<>
{value}
);
};
Добавим два компонента Counter на страницу — все работает, нет никаких проблем.
Но в реальной жизни все намного сложнее — обычно мы выносим большую часть стейта в redux. Компонент счетчика — хороший иллюстративный пример, на котором можно без труда понять концепцию.
Заменим теперь локальный state на redux. В упрощенном виде это будет выглядеть примерно так:
const Counter = () => {
const dispatch = useDispatch();
const value = useSelector(state => state.features.counter.value);
const handleClick = () => {
dispatch({ type: "INCREMENT" });
};
return (
<>
{value}
);
};
К нему мы динамически аттачим редьюсер:
export default attachReducer({
"features.counter": reducer
})(Counter);
const reducer = (state = { value: 0 }, action) => {
switch (action.type) {
case "INCREMENT":
return {
...state,
value: state.value + 1
};
default:
return state;
}
};
И все сломалось.
Если мы на страницу добавим два каунтера, то они не будут изолированными — клик на кнопке в одном компоненте будет приводить к обновлению значения не только в самом этом компоненте, а еще и в соседнем — оба они смотрят в одно место redux-стора.
Нам же нужно, чтобы в сторе было два отдельных изолированных слайса под оба компонента, и чтобы клик на кнопках этих компонентов приводил к генерации разных экшенов.
С точки зрения клиентского кода нам показалось, что было бы естественно, если бы он выглядел как-то так:
…
…
За счет пропса namespace каким-то образом мы хотим изолировать наши компоненты — создать разные редьюсеры для них и заставить их диспатчить разные экшены.
attachReducer умеет такое.
export default attachReducer((attach, props) => {
const { namespace } = props;
const actionTypes = createActionTypes(namespace);
const reducer = createReducer(actionTypes);
attach({ [`features.${namespace}`]: reducer });
const useHook = createUseHook({ actionTypes, namespace });
return { useHook };
})(Counter);
Сигнатура attachReducer, которую мы видели ранее, предполагала, что мы знаем, в какое место стора аттачить редьюсер. В нашем же случае картинка несколько иная — мы должны динамически принять решение об этом в зависимости от того, какое значение нам пришло в качестве пропса namespace.
attachReducer может в качестве конфигурации принимать не только объект, но еще и функцию, в которой мы можем динамически решить, куда и что аттачить. Вот схема того, что происходит внутри переданной в attachReducer функции

В этой функции мы:
1) Получаем namespace из пропсов
2) Создаем экшен тайпы с помощью фабрики, передавая ей namespace. Именно за счет него происходит изоляции двух фич друг от друга
2) const createActionTypes = namespace => {
3) return {
4) INCREMENT: `${namespace}.INCREMENT`
5) };
6) };
3) Создаем редьюсер с помощью фабрики, передавая ей экшн тайпы из предыдущего шага
const createReducer = actions => {
const reducer = (state = { value: 0 }, action) => {
switch (action.type) {
case actions.INCREMENT:
return {
...state,
value: state.value + 1
};
default:
return state;
}
};
return reducer;
};
4) Аттачим созданный редьюсер в нужное место стора, определяемое на базе все того же namespace. За счет этого мы изолируем данные двух фич друг от друга
5) Создаем хук, с помощью которого наш компонент будет взаимодействовать с нужным слайсом стора и генерить нужные экшены
const createUseHook = ({ actionTypes, namespace }) => {
return () => {
const dispatch = useDispatch();
const value = useSelector(state => state.features[namespace].value);
const onClick = () => {
dispatch({ type: actionTypes.INCREMENT });
};
return { value, onClick };
};
};
6) Возвращаем этот хук наружу. attachReducer передаст этот хук в качестве пропса в компонент Counter
Сам компонент так же претерпел изменения — он не смотрит в конкретное место стора и не генерит конкретные экшены. В него инъектится хук, абстрагируя эти вопросы:
const Counter = ({ useHook }) => {
const { value, onClick } = useHook();
return (
<>
{value}
);
};
Все починилось. Теперь у нас компоненты счетчиков работают изолированно, один не влияет на другой. Redux стал чуть более реюзабельным за счет redux-attachable-reducer и за счет применения паттерна фабрики
В последней версии кода компонента Counter кого-то может смутить наличие хука в качестве пропса. Это нормально. До тех пор, пока мы не нарушаем правила хуков.
Есть неплохая статья по этой теме — вот
Казалось бы, все так просто, но неожиданно это сильно улучшило developer experience в нашем продукте.
Помимо этого, мы реанимируем наш продукт, переписывая его с использованием FSD, что делает его еще более слабосвязанным и модульным, но это уже совсем другая история…