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