Scoped Store: Когда useReducer не тянет, а Redux — слишком

Страницы:  1

Ответить
 

Professor Seleznov


Всем привет, я Ислам, фронтенд-инженер, сегодня хочу разобрать такую интересную связку для локальных сложных контекстов состояний в React проектах, а именно связку React Context+useState+useReducer и как мы его можем заменить на связку Context+Zustand+useRef получая заметный профит по следующим показателям:
- Масштабируемость
- Чистота
- Оптимизация
- Простота
Почти все разработчики работали со сложными локальными состояниями где глобального Redux/MobX было слишком много, а нативные решения на основе useState/useReducer+Context были слишком громоздкими и рано или поздно превращались во франкенштейна с задатками кривого Redux, отличаясь от проекта к проекту и даже от компонента к компоненту.
Разберем реальный кейс из продакшна
Контекст: Есть приложение в котором присутствует большой модуль - лента развлекательных видео (отрезки из мультфильмов, фильмов, блогов и тд) с образовательным уклоном в формате коротких видео (tiktok, reels, shorts) с интерактивными субтитрами.
Под капотом этой фичи стоит ИИ-модель которая на вход принимает видео в оригинальном языке и выдает объект с субтитрами и метаданными. Субтитры состоят из так называемых part. Part - это цельная составная единица, которая включает в себя смысловую часть текста (как минимум одно слово). Это сделано для того, чтобы не терять смысл при переводе но и не переводить весь текст целиком, позволяя юзеру изучать предложение по смысловым частям.
Модуль предназначен для изучения языка в духе Duolingo, но вовлечение достигается за счет коротких интерактивных видео. Проблема в том, что ИИ в некоторых случаях ошибается в таймингах, переводах и сегментации токенов в part. Чтобы исправлять такие ошибки, было принято решение сделать отдельный модуль в админке - редактор интерактивных субтитров.
Страница редактора состоит из:
- Видеоплеера (vidstack)
- Таймлайна с сегментами
- Теги (опционально)
- Глобальные действия (сохранить, удалить, добавить и тд)
Редактор должен уметь всё что нужно для полноценной правки субтитров: управление плеером, визуальный таймлайн с сегментами, полный CRUD над part-ами включая разбиение, слияние и миллисекундную точность таймингов, редактирование внутреннего содержимого каждого part, управление тегами и метку текущего времени на таймлайне.
Покажу кусочек интерфейса чтобы примерно представить о чем речь:
pic
Часть интерфейса редактора субтитров
Суть проблемы: нам необходимо изолировать состояние в рамках страницы, но сделать так, чтобы это состояние мог получать и редактировать любой компонент из этой сложной композиции.
Эволюция проблемы: от наивности к «недоредаксу»
Первое что приходит в голову - использовать на топ-левеле useState/useReducer и шарить состояние и сеттер через context. Все довольно просто, шаблон понятный и очень популярный, каждый кто разрабатывал на react хоть раз прибегал к такой связке для избежания prop drilling и предсказуемого целостного управления сложным локальным стейтом.
В большинстве случаев разработка идет по пути прогрессивного проектирования чтобы не допустить оверинжиниринга, это в целом правильный подход но доставляет серьезные проблемы когда итоговая сложность конструкции недооценена.
Сначала рождается что-то типа этого:
const VideoEditorContext = createContext();
const VideoEditorProvider = ({children}) => {
const [state, setState] = useState();
return (
<VideoEditorContext.Provider value={{ state, setState }}>
{children}
</VideoEditorContext.Provider>
);
};
Но в какой-то момент стейт начинает раздуваться, и появляются жуткие конструкции:
setState(s => {
...s,
key: {
...s.key,
secondKey: {
...s.key.secondKey,
finalKey: "some_value"
}
}
})
Тут мы понимаем что код стал достаточно громоздким и непонятным и настало время подключить useReducer, после этого наш код приобретает следующий вид:
const videoEditorReducer = (state, action) => {
switch (action.type) {
case "SELECT_PART":
return ...;
case "UPDATE_PART":
return ...;
case "SPLIT_PART":
return ...;
case "SNAP_PARTS":
return ...;
// ...ещё N кейсов
}
};
И в провайдере:
const VideoEditorProvider = ({ children, initialData }) => {
const [state, dispatch] = useReducer(videoEditorReducer, {
parts: initialData.parts,
selected: [],
tags: initialData.tags,
});
return <VideoEditorContext.Provider value={{ state, dispatch }}>{children}</VideoEditorContext.Provider>;
};
Знакомо, да?
На этом этапе мы уже осознанно пишем свой недоредакс.Можно конечно же развить эту идею до функций action creator-ов чтобы нам не приходилось по строчному литералу понимать какое действие что означает - так мы окажемся еще ближе к провалу редаксу.
Вроде бы все работает, и уже лапши со спредами как в предыдущем варианте нет, проблему со сложным стейтом мы как-то решили, но, у нас есть есть еще одна серьезная проблема, особенно в контексте высокочастотных динамических компонентов - ад с ререндерами. На каждый чих мы получим ререндер всего и вся. Как ответственный инженер мы начинаем мемоизировать экшены и применить популярную технику разбиения контекста по назначению на две части:
- Контекст для изменения стейта
- Контекст для получения стейта
const VideoEditorStateContext = createContext();
const VideoEditorActionsContext = createContext();
const VideoEditorProvider = ({ children, initialData }) => {
const [state, dispatch] = useReducer(videoEditorReducer, initialData);
const actions = useMemo(() => ({
selectPart: (id) => dispatch({ type: "SELECT_PART", payload: id }),
updatePart: (id, data) => dispatch({ type: "UPDATE_PART", payload: { id, data } }),
splitPart: (id, splitTime) => dispatch({ type: "SPLIT_PART", payload: { id, splitTime } }),
// ...
}), []); // actions не меняются, dispatch стабилен
return (
<VideoEditorActionsContext.Provider value={actions}>
<VideoEditorStateContext.Provider value={state}>
{children}
</VideoEditorStateContext.Provider>
</VideoEditorActionsContext.Provider>
);
};
Выглядит умно. Но проблема до конца не решена, потому что стейт то у нас по-прежнему единый, и компонент который слушает state.tags будет ререндериться когда изменится state.parts . Мы идем еще дальше и дробим сам стейт на отдельные сущности:
const PartsContext = createContext();
const TagsContext = createContext();
const SelectionContext = createContext();
<VideoEditorActionsContext.Provider>
<PartsContext.Provider>
<TagsContext.Provider>
<SelectionContext.Provider>
{children}
</SelectionContext.Provider>
</TagsContext.Provider>
</PartsContext.Provider>
</VideoEditorActionsContext.Provider>
В целом уже неплохо. Много кода, красивый и популярный паттерн - мы молодцы… Но что если в структуру стейта нужно будет добавить еще одну сущность, например - state.originalVideoLanguage ? Еще один контекст? Очевидно, мы движемся не туда. Архитектура диктует нам дробить логически единое состояние на куски ради оптимизации. Контекстов становится больше, провайдеры выстраиваются в елочку, а новый разработчик открывает файл и …
...закрывает.
Мы боролись с prop drilling, а получили provider drilling.
Context + Zustand + useRef
И тут выходит на сцену та самая связка Context+Zustand+useRef. Zustand - одна из самых удачных имплементаций стейт-менеджмента вокруг нативного реактовского useSyncExternalStore на основе pub/sub паттерна. Используя useRef мы можем применить фабричный подход к созданию изолированного стора и хранить результат в рефе - это гарантирует нам стабильность ссылки к стору. С помощью Провайдера контекста мы выставляем искусственное ограничение по применению этого стора. Это конечно не запрещает использовать фабрику в других местах - решается на уровне соглашений в команде что любой Zustand стор который создается через фабричную функцию не должен использоваться напрямую - только через соответствующий провайдер (Scoped Store).
Что я имею в виду под фабрикой (обратите внимание на нормализацию данных, это необходимо для того чтобы селектор был стабильным и доступ был O(1)):
export const createShortStore = (initData: ShortDetails) => {
const { parts, partIds, lineOrders } = normalizeParts(initData.parts);
return createStore<Store>()(
immer(() => ({
short: initData,
parts,
tags: initData.tags
? initData.tags.map((tag) => ({ value: tag.replace(/^source:/, ""), isSource: tag.startsWith("source:") }))
: [],
partIds,
lineOrders,
editPartData: null,
selected: [],
currentSelected: null,
})),
);
};
Создаем контекст:
type StoreType = ReturnType<typeof createShortStore>;
export const ShortStoreContext = createContext<StoreType | null>(null);
Создаем хук для получения стора по селектору на основе useContext , данный подход хорош тем что оптимизация происходит на уровне useSyncExternalStore а не с помощью useMemo :
export function useShortStore<T>(selector: (state: Store) => T): T {
const store = useContext(ShortStoreContext);
if (!store) {
throw new Error("...");
}
return useStore(store, selector);
}
Кроме этого можем создать еще один хук - для получения инстанса стора:
export function useShortStoreApi() {
const store = useContext(ShortStoreContext);
return store;
}
И наконец наш провайдер:
export const StoreProvider: FC<Props> = ({ children, data }) => {
const storeRef = useRef(createShortStore(data));
return <ShortStoreContext.Provider value={storeRef.current}>{children}</ShortStoreContext.Provider>;
};
И хук для экшнов, изолируем наши функции для изменения стора:
export function useShortActions() {
const store = useShortStoreApi();
function addPart() {
store.setState(...)
}
function mergeParts() {
store.setState(...)
}
function splitParts() {
store.setState(...)
}
function updateTag() {
store.setState(...)
}
function changeTokensOrder() {
store.setState(...)
}
// и еще какие-то экшены
return {
addPart,
mergeParts,
splitParts,
updateTag,
changeTokensOrder,
//...
};
}
Доступ к стору получаем только в моменте изменения, никакой прямой подписки на стор. Можем пойти еще дальше раздробив наш хук для экшенов по сущностям - но это уже по необходимости.
Все)
parts, tags, selected - всё это теперь живёт в одном сторе, без елочки провайдеров и без useMemo как костыля. Компонент PartsList подписывается только на partIds и не знает что происходит с тегами и с part-ми. Компонент Part подписывается к своему сегменту по id. Панель редактирования читает только выбранный part — и не реагирует на смену currentTime. Метка времени живет отдельно, напрямую слушая currentTime через сигналы Vidstack. Каждый компонент платит ровно за то что потребляет.
Добавить новую сущность - одна строка в сторе. Экшны не завязаны к стору. Никаких новых провайдеров, новых контекстов и елочек.
Итог
- Масштабируемость - стор растёт линейно, не экспоненциально
- Чистота - один провайдер, один стор, понятные экшены в виде функций
- Оптимизация - селекторы вместо useMemo, useSyncExternalStore под капотом
- Простота - новый разработчик открывает файл и не закрывает его)
Context+useState/useReducer не плохой инструмент. Он хорошо справляется с простыми локальными состояниями, но когда сложность растёт - начинает разваливаться.
Zustand+useRef+Context - это не оверинжиниринг и не попытка притащить глобальный стейт менеджер куда не просят. Это тот же привычный паттерн с провайдером и хуком, только с нормальным инструментом внутри.-Источник
 
Loading...
Error