Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Discriminated Unions в React State: устранение невалидных состояний

Тимофей Ищенко

Коротко: Как использовать Discriminated Unions в React и TypeScript: избавляемся от булевых флагов, предотвращаем UI-баги и настраиваем исчерпывающую проверку стейта.

Каждый фронтенд-разработчик сталкивался с ситуацией, когда UI-компонент одновременно отображает спиннер загрузки, старые данные и плашку с ошибкой. Причина такого поведения кроется в архитектурной ошибке моделирования состояния: независимые булевые флаги и разрозненные переменные допускают комбинации, которых в бизнес-логике существовать не должно.

В проектировании типов есть фундаментальный принцип: Make invalid states unrepresentable (делайте невалидные состояния непредставимыми). Лучший инструмент для реализации этого принципа в связке TypeScript и React — размеченные объединения (Discriminated Unions). Они переносят валидацию логики с этапа выполнения (runtime) на этап компиляции, устраняя скрытые дефекты интерфейса еще до первого запуска тестов.


Анатомия проблемы: почему независимые useState плодят баги

Рассмотрим классический шаблон компонента, загружающего данные из API. Большинство разработчиков начинают с набора независимых хуков useState.

// Типичный антипаттерн
const UserProfile = ({ userId }: { userId: string }) => {
  const [data, setData] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);

  const fetchUser = async () => {
    setIsLoading(true);
    setError(null);
    try {
      const response = await api.getUser(userId);
      setData(response);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      setIsLoading(false);
    }
  };
  // ...
};

Коллизии состояний и рассинхронизация UI (Ghost States)

На первый взгляд код выглядит привычно, но он создает $2^3 = 8$ теоретически возможных комбинаций состояния. При этом бизнес-логика валидирует только 4:

  1. Начальное состояние (idle): нет данных, нет загрузки, нет ошибки.
  2. Загрузка (loading): идет запрос, данных нет, ошибки нет.
  3. Успех (success): данные есть, загрузки нет, ошибки нет.
  4. Ошибка (error): данных нет, загрузки нет, есть сообщение об ошибке.

Остальные 4 комбинации — это «фантомные состояния» (Ghost States):

  • isLoading: true и error: "Network Error" одновременно.
  • isLoading: false, data: null, error: null при перезапросе.
  • data: User и error: "Forbidden" одновременно (например, если забыли очистить ошибку перед установкой новых данных).

Когда компонент разрастается, разработчикам приходится писать монструозные конструкции рендеринга:

// Хрупкая цепочка тернарных операторов
return (
  <div>
    {isLoading && <Spinner />}
    {!isLoading && error && <ErrorMessage text={error} />}
    {!isLoading && !error && data && <UserDetails user={data} />}
    {!isLoading && !error && !data && <EmptyPlaceholder />}
  </div>
);

Любая ошибка в порядке условий или пропущенный флаг в set-функции приводит к мерцанию интерфейса или отрисовке некорректных экранов.


Что такое Discriminated Unions в TypeScript и как работает сужение типов

Discriminated Union (размеченное, тегированное объединение) — это объединение объектных типов, каждый из которых содержит общее поле с литеральным типом. Это поле выступает в роли метки (дискриминанта, тега).

Структура размеченного объединения

Дискриминантом обычно служит поле status, type или kind. Значения должны быть строковыми или числовыми литералами, а не общим типом string.

type IdleState = {
  status: 'idle';
};

type LoadingState = {
  status: 'loading';
};

type SuccessState = {
  status: 'success';
  data: User;
};

type ErrorState = {
  status: 'error';
  error: string;
};

// Размеченное объединение
type UserState = IdleState | LoadingState | SuccessState | ErrorState;

Механизм Type Narrowing (сужение типов)

Компилятор TypeScript понимает связь между литеральным полем и структурой объекта. Как только вы проверяете значение status через if или switch, TypeScript автоматически отсекает неподходящие типы.

const renderContent = (state: UserState) => {
  if (state.status === 'success') {
    // Внутри этого блока TypeScript гарантирует:
    // 1. Поле state.data существует и имеет тип User
    // 2. Поля state.error здесь не существует
    return <UserDetails user={state.data} />;
  }

  if (state.status === 'error') {
    // Здесь state.error — строка, а state.data недоступно
    return <ErrorMessage text={state.error} />;
  }
};

Попытка обратиться к state.data вне ветки 'success' вызовет ошибку на этапе компиляции. Невалидные комбинации данных исключаются на уровне системы типов.


Рефакторинг состояния React: от разрозненных флагов к единой модели

Объединим разрозненные useState в один атомарный стейт с использованием Discriminated Union.

import React, { useState, useEffect } from 'react';

type User = {
  id: string;
  name: string;
  email: string;
};

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

export const UserCard = ({ userId }: { userId: string }) => {
  const [state, setState] = useState<AsyncState<User>>({ status: 'idle' });

  useEffect(() => {
    let isMounted = true;
    setState({ status: 'loading' });

    fetch(`/api/users/${userId}`)
      .then((res) => {
        if (!res.ok) throw new Error('Ошибка при загрузке данных');
        return res.json() as Promise<User>;
      })
      .then((data) => {
        if (isMounted) {
          setState({ status: 'success', data });
        }
      })
      .catch((err: unknown) => {
        if (isMounted) {
          setState({
            status: 'error',
            error: err instanceof Error ? err.message : 'Неизвестная ошибка',
          });
        }
      });

    return () => {
      isMounted = false;
    };
  }, [userId]);

  switch (state.status) {
    case 'idle':
      return <p>Ожидание запуска...</p>;
    case 'loading':
      return <Spinner />;
    case 'error':
      return <ErrorMessage text={state.error} />;
    case 'success':
      return <UserDetails user={state.data} />;
  }
};

Преимущества такого подхода:

  1. Атомарные апдейты: Невозможно изменить статус, забыв сбросить ошибку или данные.
  2. Чистый JSX: Логика отображения сводится к прямому маппингу вариантов через switch.
  3. Строгий контракт: Поле data существует только при status: 'success'.

Интеграция с useReducer для детерминированных переходов

Если компонент содержит сложную логику переходов (например, повторные запросы, пагинацию или сохранение предыдущих данных при фоновом обновлении), связка Discriminated Unions и хука useReducer работает как легковесный конечный автомат (Finite State Machine).

В useReducer объединения используются на двух уровнях: для состояния (State) и для действий (Action).

// Описание возможных действий (Actions)
type Action =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: User }
  | { type: 'FETCH_FAILURE'; payload: string }
  | { type: 'RESET' };

// Редуктор с гарантией валидных переходов
const userReducer = (state: AsyncState<User>, action: Action): AsyncState<User> => {
  switch (action.type) {
    case 'FETCH_START':
      return { status: 'loading' };
    case 'FETCH_SUCCESS':
      return { status: 'success', data: action.payload };
    case 'FETCH_FAILURE':
      return { status: 'error', error: action.payload };
    case 'RESET':
      return { status: 'idle' };
    default:
      return state;
  }
};

Такая структура исключает возникновение промежуточных «поломанных» состояний при любых действиях пользователя или асинхронных ответах.


Исчерпывающая проверка (Exhaustiveness Checking) с типом never

При активной разработке состав объединения периодически расширяется. Например, к статусам может добавиться 'empty' (когда запрос выполнен успешно, но список элементов пуст) или 'revalidating'.

Если разработчик добавит новый вариант в union-тип, но забудет обработать его в блоке switch, интерфейс может вернуть undefined или проигнорировать отрисовку.

Для защиты от таких сценариев используется исчерпывающая проверка через тип never.

Реализация утилиты assertNever

export function assertNever(x: never): never {
  throw new Error(`Необработанный вариант объединения: ${JSON.stringify(x)}`);
}

Применение в компоненте

type ExtendedState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'empty' }
  | { status: 'error'; error: string };

const renderView = (state: ExtendedState<User[]>) => {
  switch (state.status) {
    case 'idle':
      return <Placeholder />;
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserList items={state.data} />;
    case 'empty':
      return <EmptyView />;
    case 'error':
      return <ErrorBanner message={state.error} />;
    default:
      // Если мы добавим новый статус и не создадим для него case,
      // TypeScript выдаст ошибку компиляции именно на этой строке:
      // Argument of type 'NewState' is not assignable to parameter of type 'never'
      return assertNever(state);
  }
};

Благодаря assertNever рефакторинг становится безопасным: компилятор сам подсвечивает все места в кодовой базе, где новый вариант еще не обработан.


Взаимоисключающие пропсы: типизация компонентов через размеченные объединения

Паттерн применим не только к стейту, но и к пропсам компонентов. Классический пример — модальные окна, кнопки и поля ввода, поведение которых кардинально меняется в зависимости от режима.

Представим компонент кнопки Button, которая может быть либо ссылкой (требует href), либо обычной кнопкой (требует onClick), либо кнопкой отправки формы.

Антипаттерн: опциональные пропсы

// Плохо: можно передать href и onClick одновременно, или не передать ничего
type BadButtonProps = {
  as?: 'button' | 'link';
  href?: string;
  onClick?: () => void;
  children: React.ReactNode;
};

Решение через Discriminated Unions

type BaseButtonProps = {
  variant?: 'primary' | 'secondary';
  children: React.ReactNode;
};

type LinkButtonProps = BaseButtonProps & {
  as: 'link';
  href: string;
  target?: string;
  onClick?: never;
};

type ActionButtonProps = BaseButtonProps & {
  as: 'button';
  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
  disabled?: boolean;
  href?: never;
};

type SubmitButtonProps = BaseButtonProps & {
  as: 'submit';
  disabled?: boolean;
  onClick?: never;
  href?: never;
};

type ButtonProps = LinkButtonProps | ActionButtonProps | SubmitButtonProps;

export const Button = (props: ButtonProps) => {
  if (props.as === 'link') {
    return (
      <a href={props.href} target={props.target} className={props.variant}>
        {props.children}
      </a>
    );
  }

  if (props.as === 'submit') {
    return (
      <button type="submit" disabled={props.disabled} className={props.variant}>
        {props.children}
      </button>
    );
  }

  return (
    <button type="button" onClick={props.onClick} disabled={props.disabled} className={props.variant}>
      {props.children}
    </button>
  );
};

Теперь IDE выдаст ошибку еще на этапе набора кода, если вы попытаетесь передать href в кнопку с as="button".


Когда Discriminated Unions незаменимы, а когда избыточны

Размеченные объединения — мощный инструмент, но его применение должно быть осознанным.

Незаменимы, когда:

  • Состояние содержит взаимоисключающие наборы данных (data vs error).
  • Рендеринг зависит от дискретных шагов процесса (визарды оформления заказа, многофакторная аутентификация).
  • Логика компонента содержит больше двух связанных булевых флагов.
  • Требуется жесткий контракт для вариативных UI-компонентов.

Избыточны, когда:

  • Компонент управляет независимыми элементами управления (например, состояние открытости дропдауна isOpen: boolean никак не связано со значением текстового поля).
  • Вы работаете с простыми контролируемыми полями форм (для них достаточно обычных примитивов или специализированных библиотек вроде React Hook Form).

FAQ (Часто задаваемые вопросы)

1. Чем Discriminated Union отличается от обычного Union (TypeA | TypeB)?

В обычном объединении (TypeA | TypeB) у типов может не быть общего однозначного идентификатора. Для их различения приходится использовать оператор in ('data' in state), instanceof или пользовательские Type Guards (isUser(obj)). В Discriminated Union всегда присутствует общее поле с литеральным типом (дискриминант), по которому компилятор TypeScript мгновенно и нативно выполняет сужение без лишних проверок в рантайме.

2. Как Discriminated Unions сочетаются с библиотеками серверного стейта (TanStack Query, RTK Query)?

Библиотеки вроде TanStack Query (React Query) внутри себя реализуют похожий подход, предоставляя статус запроса (status: 'pending' | 'error' | 'success'). Однако при создании собственных хуков-оберток или нормализации данных под доменную логику явное приведение ответа к Discriminated Union позволяет инкапсулировать работу с кэшем и защитить дочерние компоненты от неполных данных.

3. Можно ли использовать enum вместо строковых литералов в качестве дискриминанта?

Технически можно, но строковые литеральные объединения ('idle' | 'loading') предпочтительнее. Они не генерируют лишний JavaScript-код при компиляции, лучше сериализуются, прозрачно выводятся в логах и сообщениях об ошибках, а также проще комбинируются без необходимости импортировать объект enum во все файлы.

4. Как правильно типизировать сброс состояния к начальному (idle)?

Для сброса достаточно передать объект, соответствующий начальной ветке объединения. Если вы используете useState:

const handleReset = () => {
  setState({ status: 'idle' });
};

Поскольку поле status: 'idle' не требует наличия data или error, стейт возвращается в исходное валидное состояние без необходимости очищать каждую переменную по отдельности.

5. Как сохранить предыдущие данные при обновлении (Background Revalidation)?

Если необходимо показывать старые данные во время загрузки новых (паттерн Stale-While-Revalidate), расширьте тип загрузки:

type AsyncStateWithKeepPrevious<T> =
  | { status: 'idle' }
  | { status: 'loading'; previousData?: T }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string; previousData?: T };

Это позволит безопасно рендерить фоновые спиннеры поверх существующего контента.


Вывод

Использование независимых булевых переменных для контроля UI-состояний — частый источник техдолга и багов. Переход на Discriminated Unions позволяет:

  1. Полностью устранить невалидные комбинации данных в компонентах.
  2. Упростить чтение и поддержку JSX-разметки за счет отказа от цепочек тернарных операторов.
  3. Получить надежную защиту компилятора при рефакторинге через исчерпывающую проверку (never).

Моделируйте состояние как конечный набор строго определенных вариантов — это сделает код предсказуемым, тестируемым и устойчивым к изменениям требований.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также