Всем привет, я Ислам, фронтенд-инженер, сегодня хочу разобрать такую интересную связку для локальных сложных контекстов состояний в 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, управление тегами и метку текущего времени на таймлайне.
Покажу кусочек интерфейса чтобы примерно представить о чем речь:

Суть проблемы: нам необходимо изолировать состояние в рамках страницы, но сделать так, чтобы это состояние мог получать и редактировать любой компонент из этой сложной композиции.
Эволюция проблемы: от наивности к «недоредаксу»
Первое что приходит в голову - использовать на топ-левеле 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 - это не оверинжиниринг и не попытка притащить глобальный стейт менеджер куда не просят. Это тот же привычный паттерн с провайдером и хуком, только с нормальным инструментом внутри.
