Управление состоянием в React: Глубокое погружение в Context и useReducer без лишних ререндеров

Для React-разработчиков решение о структуре состояния приложения часто сводится к выбору: поднимать state до общего родителя или внедрять Redux/Zustand. Но есть третий путь, который часто упускают из виду — комбинация Context и useReducer. Этот тандем обеспечивает предсказуемость Flux-подобных систем без накладных расходов сторонних библиотек. Как избежать фатальных ошибок в производительности и правильно выстроить архитектуру? Перед вами — инженерное руководство.

Почему именно эта связка?

Пропс-дриллинг разрушает поддержку кода, а Redux для простых сценариев — это избыточно. Context + useReducer предлагает локализованное глобальное состояние:

  • Централизованная логика обновлений через reducer
  • Доступ к данным из любой части приложения
  • Нулевые дополнительные зависимости
  • Нативная интеграция с Concurrent Mode

Проблема в том, что наивная реализация гарантированно выстрелит в ногу производительности. Рассмотрим проблему на живом примере.

Типичная ошибка: ререндер всего подряд

Представьте классический провайдер:

jsx
const UserContext = React.createContext();

export function UserProvider({ children }) {
  const [state, dispatch] = useReducer(userReducer, initialState);
  
  return (
    <UserContext.Provider value={{ state, dispatch }}>
      {children}
    </UserContext.Provider>
  );
}

Казалось бы, элегантно. Но любой компонент, использующий useContext(UserContext), будет перерисовываться при каждом изменении state — даже если ему нужна лишь маленькая часть данных. В приложении среднего размера это гарантирует лавину ререндеров.

Решение: разделяй и властвуй

Ключ к оптимизации — сегментация контекста и мемоизация. Разделим данные и операции:

jsx
// state.js
export const UserStateContext = React.createContext(null);
export const UserDispatchContext = React.createContext(null);

// provider.jsx
export function UserProvider({ children }) {
  const [state, dispatch] = useReducer(userReducer, initialState);
  
  return (
    <UserStateContext.Provider value={state}>
      <UserDispatchContext.Provider value={dispatch}>
        {children}
      </UserDispatchContext.Provider>
    </UserStateContext.Provider>
  );
}

// hook.js
export function useUserState() {
  const context = React.useContext(UserStateContext);
  if (context === undefined) throw new Error('...');
  return context;
}

export function useUserDispatch() {
  const context = React.useContext(UserDispatchContext);
  if (context === undefined) throw new Error('...');
  return context;
}

Теперь компонент, использующий только useUserDispatch(), не будет перерисовываться при изменении состояния. Диспетчер — стабильная функция.

Архитектура редьюсера: как не превратить код в ад

Ошибка новичков — создание монолитного редьюсера на 500 строк. Решение — доменная декомпозиция:

javascript
function userReducer(state, action) {
  switch (action.type) {
    case 'LOGIN_SUCCESS':
      return { 
        ...state, 
        user: action.payload.user,
        status: 'authenticated'
      };
    case 'FETCH_PROFILE_FAILURE':
      return {
        ...state,
        profile: null,
        error: action.payload.error
      };
    default:
      return state;
  }
}

Но что если необходимы сайд-эффекты? Используйте паттерн «команд с эффектами»:

javascript
function userReducer(state, action) {
  if (action.type === 'LOGIN') {
    // Инициируем процесс во внешнем мире
    action.callback?(state.credentials); 
    return { ...state, isLoading: true };
  }
  // ...
}

Важно: эффекты вне редьюсера (через useEffect или thunk) предпочтительнее для сохранения воспроизводимого состояния.

Контроль ререндеров: точечная выборка данных

Даже с разделённым контекстом выборка объекта целиком приводит к ререндерам при обновлении любого его поля. Решение — мемоизированные селекторы:

jsx
const selectUserProfile = state => state.profile;

function UserProfile() {
  const dispatch = useUserDispatch();
  const profile = useUserState(selectUserProfile); // Кастомный хук (читай ниже)

  return <ProfileCard data={profile} />;
}

Реализуем оптимизированый хук:

javascript
export function useUserState(selector) {
  const state = React.useContext(UserStateContext);
  sel = useMemo(() => selector, [selector]); 
  return useMemo(() => sel(state), [state, sel]);
}

selector выступает функцией проекции — компонент получит ререндер только если результат selector(state) изменился (проверка через shallow equal).

Типизация на TypeScript: полная безопасность

Выведите интерфейсы автоматически из редьюсера:

typescript
type Action =
  | { type: 'LOGIN'; payload: { email: string } }
  | { type: 'LOGOUT'; meta?: { silent: boolean } };

type State = {
  user: User | null;
  error: string | null;
};

export function useUserDispatch() {
  const context = useContext(UserDispatchContext);
  // context имеет тип React.Dispatch<Action>
}

Такой подход исключает невалидные экшены и гарантирует корректность в рантайме.

Когда не использовать этот подход?

Стоит предусмотреть случаи, когда Context не лучший выбор:

  • Высокочастотные обновления (анимации, драг-н-дроп)
  • Общие данные между микросервисными частями приложения
  • Состояние со сложными inter-dependencies

Для этих сценариев присмотритесь к Zustand, Jotai или Recoil.

Итоговый инжиниринговый чеклист

  1. Физическое разделение State/Dispatch контекстов – защита от «случайных» ререндеров.
  2. Доменный редьюсер – никакой бизнес-логики кроме преобразования (state, action) => nextState.
  3. Селекторы с мемоизацией – используйте с автономными селекторными функциями.
  4. Строгие границы – один контекст на домен, не создавайте God Context.
  5. Статическая типизация – редкий случай, когда TypeScript оправдан на 200%.

Этот метод не серебряная пуля, но для большинства приложений средней сложности Context + useReducer покрывает все требования к состоянию с минимальным оверхедом. Пример выше масштабируется до уровня Airbnb при правильном зонировании модулей. Забудьте пропс-дриллинг и переусложнённые нарративы — иногда достаточно встроенных инструментов.