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

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

Типизация React Context без undefined: пошаговая реализация паттерна createSafeContext на TypeScript

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

Коротко: Разбираем паттерн createSafeContext в React и TypeScript: избавляемся от undefined и null!, настраиваем fail-fast валидацию и создаем удобный хук.

Работа с React Context в связке с TypeScript при включенном режиме strictNullChecks регулярно приводит к одной и той же проблеме: функция createContext требует обязательное начальное значение при инициализации. Если актуальные данные формируются только внутри провайдера на основе пропсов или хуков, разработчикам приходится передавать undefined либо null. В итоге каждый вызов useContext возвращает тип T | undefined, вынуждая писать бесконечные проверки if (!context) или использовать небезопасный оператор non-null assertion (!).

Паттерн createSafeContext решает эту проблему на уровне архитектуры: он инкапсулирует runtime-проверку внутри кастомного хука, автоматически сужает возвращаемый тип до чистого T и защищает приложение от неявных ошибок при случайном вызове контекста вне дерева компонентов провайдера.


Анатомия проблемы: почему createContext заставляет писать костыли

Сигнатура React.createContext<T>(defaultValue: T) изначально создавалась с расчетом на то, что у контекста всегда есть осмысленное значение по умолчанию. На практике глобальные хранилища, сессии авторизации или внутреннее состояние сложных UI-компонентов зависят от жизненного цикла приложения и пропсов провайдера. На этапе декларации контекста этих данных попросту нет.

В типичных проектах эту нестыковку обходят тремя путями, и у каждого есть серьезные недостатки.

Вариант 1: createContext<T | undefined>(undefined)

interface AuthContextValue {
  user: { id: string; name: string };
  logout: () => void;
}

const AuthContext = React.createContext<AuthContextValue | undefined>(undefined);

export const useAuth = () => {
  const context = React.useContext(AuthContext);
  // Тип context: AuthContextValue | undefined
  // Потребителю приходится постоянно делать проверку:
  // if (!context) { ... } или использовать context?.user.name
  return context;
};

Этот подход честен перед компилятором, но перекладывает рутинную проверку на каждого потребителя. В коде множатся шаблонные защитные условия, а случайный пропуск приводит к ошибкам сборки или постоянному нагромождению операторов опциональной последовательности (?.).

Вариант 2: Иллюзия безопасности через null!

const AuthContext = React.createContext<AuthContextValue>(null!);

Использование оператора non-null assertion (!) принудительно отключает строгий контроль TypeScript. Компилятор уверен, что контекст всегда инициализирован объектом AuthContextValue. Если компонент по ошибке отрендерится вне <AuthContext.Provider>, приложение упадет во время выполнения с ошибкой вроде:

Uncaught TypeError: Cannot read properties of null (reading 'user')

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

Вариант 3: Искусственные дефолтные объекты-заглушки

const AuthContext = React.createContext<AuthContextValue>({
  user: { id: '', name: '' },
  logout: () => {},
});

Создание фиктивных объектов маскирует архитектурные ошибки. Компонент, забытый вне провайдера, не выбрасывает ошибку, а продолжает тихо работать с некорректным состоянием и вызывать пустые функции. Это затрудняет отладку и засоряет память неиспользуемыми объектами.


Концепция createSafeContext: как работает паттерн

Идея createSafeContext строится на двух фундаментальных принципах: fail-fast валидации во время выполнения и сужении типов (Type Narrowing) на уровне TypeScript.

Вместо дублирования проверок в компонентах мы используем фабрику. Она инициализирует внутренний контекст значением null, но наружу отдает типизированный компонент-провайдер и кастомный хук. Внутри хука выполняется проверка: если контекст вернул null, выбрасывается информативное исключение с именем провайдера.

Благодаря защитному условию (throw new Error) TypeScript автоматически отсекает null из типа возвращаемого значения, гарантируя потребителю строгий тип T.


Пошаговая реализация createSafeContext на TypeScript

Спроектируем универсальную утилиту, готовую к внедрению в production-код.

Шаг 1: Определение интерфейсов и параметров

Функция должна быть обобщенной (Generic) и принимать параметры для конфигурации сообщения об ошибке:

import React, { createContext, useContext } from 'react';

export interface CreateSafeContextOptions {
  name?: string;
  errorMessage?: string;
}

Шаг 2: Создание контекста и валидирующего хука

Создаем контекст с типом T | null и настраиваем хук, который проверяет наличие провайдера:

export function createSafeContext<T>(options: CreateSafeContextOptions = {}) {
  const { 
    name = 'SafeContext', 
    errorMessage = `[Context Error]: use${name}Context must be used within <${name}.Provider />` 
  } = options;

  const Context = createContext<T | null>(null);
  Context.displayName = name;

  const useSafeContext = (): T => {
    const value = useContext(Context);

    if (value === null) {
      throw new Error(errorMessage);
    }

    return value;
  };

  return [Context.Provider, useSafeContext, Context] as const;
}

Шаг 3: Формирование типизированного Provider-компонента

Чтобы скрыть детали работы с внутренним контекстом и сделать пропсы максимально строгими, выделим компонент-обертку, принимающий value: T и children: React.ReactNode.


Готовое решение: финальный код утилиты

Ниже представлен законченный модуль, который можно разместить в каталоге src/shared/lib или в пакете внутренней дизайн-системы:

import React, { createContext, useContext } from 'react';

export interface CreateSafeContextOptions {
  /** Имя контекста для отображения в React DevTools и сообщениях об ошибках */
  name?: string;
  /** Пользовательский текст ошибки, если хук вызван вне Provider */
  errorMessage?: string;
}

export interface SafeProviderProps<T> {
  value: T;
  children: React.ReactNode;
}

export type SafeContextTuple<T> = readonly [
  React.FC<SafeProviderProps<T>>,
  () => T,
  React.Context<T | null>
];

/**
 * Создает типобезопасный React Context без undefined и null.
 * Автоматически валидирует вызов хука внутри соответствующего Provider.
 */
export function createSafeContext<T>(
  options: CreateSafeContextOptions = {}
): SafeContextTuple<T> {
  const {
    name = 'SafeContext',
    errorMessage = `[Context Error]: Hook must be used within <${name}Provider />`,
  } = options;

  const Context = createContext<T | null>(null);
  Context.displayName = name;

  const Provider: React.FC<SafeProviderProps<T>> = ({ value, children }) => {
    return <Context.Provider value={value}>{children}</Context.Provider>;
  };

  const useSafeContext = (): T => {
    const context = useContext(Context);

    if (context === null) {
      throw new Error(errorMessage);
    }

    return context;
  };

  return [Provider, useSafeContext, Context] as const;
}

Практический пример: применение в составном компоненте (UI-Kit)

Рассмотрим, как фабрика упрощает создание составных компонентов (Compound Components) на примере компонента аккордеона.

import React, { useState } from 'react';
import { createSafeContext } from './createSafeContext';

// 1. Описываем контракт контекста
interface AccordionContextValue {
  activeId: string | null;
  toggleItem: (id: string) => void;
}

// 2. Генерируем провайдер и хук в одну строчку
const [AccordionProvider, useAccordionContext] = createSafeContext<AccordionContextValue>({
  name: 'Accordion',
});

// 3. Корневой компонент
export interface AccordionProps {
  children: React.ReactNode;
  defaultActiveId?: string | null;
}

export const Accordion: React.FC<AccordionProps> = ({
  children,
  defaultActiveId = null,
}) => {
  const [activeId, setActiveId] = useState<string | null>(defaultActiveId);

  const toggleItem = (id: string) => {
    setActiveId((prev) => (prev === id ? null : id));
  };

  return (
    <AccordionProvider value={{ activeId, toggleItem }}>
      <div className="accordion-root">{children}</div>
    </AccordionProvider>
  );
};

// 4. Дочерний компонент-потребитель
export interface AccordionItemProps {
  id: string;
  title: string;
  children: React.ReactNode;
}

export const AccordionItem: React.FC<AccordionItemProps> = ({
  id,
  title,
  children,
}) => {
  // Хук возвращает AccordionContextValue без undefined и null
  const { activeId, toggleItem } = useAccordionContext();
  const isOpen = activeId === id;

  return (
    <div className="accordion-item">
      <button 
        type="button" 
        onClick={() => toggleItem(id)}
        aria-expanded={isOpen}
      >
        {title}
      </button>
      {isOpen && <div className="accordion-content">{children}</div>}
    </div>
  );
};

Если разработчик случайно отрендерит <AccordionItem /> вне <Accordion />, приложение немедленно остановит выполнение и выведет понятную ошибку: [Context Error]: Hook must be used within <AccordionProvider />.


Кортеж против объекта: какой формат возврата выбрать?

Существует два распространенных формата возврата из фабрики контекста.

Вариант 1: Кортеж (Tuple)

const [Provider, useMyContext] = createSafeContext<ContextValue>({ name: 'MyComponent' });
  • Преимущества: Удобно и компактно переименовывать сущности при деструктуризации без дополнительного синтаксиса псевдонимов. Этот подход активно применяется в таких библиотеках, как Mantine и Chakra UI.
  • Недостатки: Строгий порядок аргументов при распаковке.

Вариант 2: Объект

const { Provider, useSafeContext } = createSafeContext<ContextValue>({ name: 'MyComponent' });
  • Преимущества: Именованные поля защищают от ошибок в порядке передачи аргументов.
  • Недостатки: Переименование требует более громоздкого синтаксиса: { Provider: DropdownProvider, useSafeContext: useDropdown }.

Для большинства сценариев кортеж [Provider, useSafeContext, Context] as const является наиболее практичным и выразительным решением.


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

Зачем выбрасывать ошибку в рантайме, если TypeScript проверяет типы при сборке?

TypeScript осуществляет статический анализ, но не может валидировать взаимное расположение компонентов в JSX-дереве во время выполнения. Компилятор не знает, вложен ли данный дочерний компонент в соответствующий провайдер. Runtime-проверка закрывает этот пробел.

Влияет ли createSafeContext на производительность?

Накладные расходы отсутствуют. При вызове хука выполняется единственная проверка на равенство null. Чтобы оптимизировать производительность самого React-дерева, следите за стабильностью объекта, передаваемого в value (мемоизируйте его с помощью useMemo, если он создается динамически внутри родительского компонента).

Что делать, если null или undefined являются допустимыми рабочими значениями контекста?

Если null или undefined входят в спектр валидных данных, в качестве маркера отсутствия провайдера используется уникальный символ:

const EMPTY_SYMBOL = Symbol('SafeContextEmpty');

В этом случае контекст инициализируется данным символом, а проверка в хуке выглядит как if (context === EMPTY_SYMBOL).

Совместим ли паттерн с Server Components в Next.js (App Router)?

React Context предназначен исключительно для клиентского дерева компонентов. В Server Components контекст не поддерживается. Модули, использующие createSafeContext и клиентские хуки, должны содержать директиву 'use client' в начале файла.

Чем это решение отличается от библиотеки @radix-ui/react-context?

Пакет Radix UI решает схожую задачу, но дополнительно включает механизм изолированных областей видимости (scope contexts) для сложных составных интерфейсов с множественной вложенностью. Для подавляющего большинства прикладных приложений и стандартных компонентов UI-кита достаточно компактной функции createSafeContext.


Вывод

Паттерн createSafeContext устраняет накопление технического долга, избавляет кодовую базу от небезопасного оператора null! и убирает лишние условные ветвления. Внедрение этой утилиты в проект делает контракт компонентов прозрачным, интерфейсы хуков — строгими, а процесс отладки — предсказуемым.

Источники

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

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