← Курс/TypeScript с React: Context API#191 из 257+25 XP

TypeScript с React: Context API

createContext с типом

createContext принимает дженерик-параметр для типа значения:

interface ThemeContextType {
  theme: 'light' | 'dark'
  toggleTheme: () => void
}

// Вариант 1: с начальным значением (undefined потребует проверки)
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)

// Вариант 2: с fake начальным значением (небезопасно, но удобно)
const ThemeContext = createContext<ThemeContextType>({} as ThemeContextType)

Безопасный паттерн с undefined

Лучший подход — передавать undefined как начальное значение и бросать ошибку при использовании вне провайдера:

interface AuthContextType {
  user: User | null
  login: (credentials: Credentials) => Promise<void>
  logout: () => void
  isLoading: boolean
}

const AuthContext = createContext<AuthContextType | undefined>(undefined)

// Кастомный хук с проверкой:
function useAuth(): AuthContextType {
  const context = useContext(AuthContext)
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}

Провайдер с типизацией

interface AuthProviderProps {
  children: React.ReactNode
  initialUser?: User | null
}

function AuthProvider({ children, initialUser = null }: AuthProviderProps) {
  const [user, setUser] = useState<User | null>(initialUser)
  const [isLoading, setIsLoading] = useState(false)

  const login = async (credentials: Credentials) => {
    setIsLoading(true)
    try {
      const user = await authApi.login(credentials)
      setUser(user)
    } finally {
      setIsLoading(false)
    }
  }

  const logout = () => {
    setUser(null)
    authApi.logout()
  }

  const value: AuthContextType = { user, login, logout, isLoading }

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  )
}

Дженерик контекст

// Переиспользуемый паттерн для любого контекста
function createTypedContext<T>(name: string) {
  const Context = createContext<T | undefined>(undefined)

  function useTypedContext(): T {
    const ctx = useContext(Context)
    if (ctx === undefined) {
      throw new Error(`use${name} must be used within ${name}Provider`)
    }
    return ctx
  }

  return [Context.Provider, useTypedContext] as const
}

// Использование:
interface CartContextType {
  items: CartItem[]
  addItem: (item: CartItem) => void
  total: number
}

const [CartProvider, useCart] = createTypedContext<CartContextType>('Cart')

Несколько контекстов: composition

function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <AuthProvider>
      <ThemeProvider>
        <CartProvider>
          {children}
        </CartProvider>
      </ThemeProvider>
    </AuthProvider>
  )
}

Контекст с useReducer

type AppAction =
  | { type: 'SET_USER'; payload: User }
  | { type: 'SET_THEME'; payload: 'light' | 'dark' }
  | { type: 'RESET' }

interface AppState {
  user: User | null
  theme: 'light' | 'dark'
}

interface AppContextType {
  state: AppState
  dispatch: React.Dispatch<AppAction>
}

const AppContext = createContext<AppContextType | undefined>(undefined)

Оптимизация: разделение на read/write контексты

// Разделяем на два контекста чтобы избежать лишних ре-рендеров
const ThemeStateContext = createContext<'light' | 'dark'>('light')
const ThemeDispatchContext = createContext<() => void>(() => {})

function useThemeState() { return useContext(ThemeStateContext) }
function useThemeDispatch() { return useContext(ThemeDispatchContext) }

Примеры

Context API симуляция в чистом JS: createContext, Provider, useContext — полная реализация паттернов из React TypeScript

// Реализуем Context API с нуля — как работает React Context внутри.
// В TypeScript каждый контекст строго типизирован.

// --- Минимальный Context API ---
let contextRegistry = new Map()
let currentConsumer = null

function createContext(defaultValue) {
  const contextId = Symbol('context')

  const Context = {
    _id: contextId,
    _currentValue: defaultValue,
    Provider: null,  // будет заполнено ниже
  }

  Context.Provider = function(value, children) {
    const prev = Context._currentValue
    Context._currentValue = value
    const result = children()
    Context._currentValue = prev  // восстанавливаем (вложенные провайдеры)
    return result
  }

  return Context
}

function useContext(Context) {
  return Context._currentValue
}

// --- Типизированный паттерн createTypedContext ---
// В TS: function createTypedContext<T>(name: string): [Provider, () => T]
function createTypedContext(name, defaultValue = undefined) {
  const Context = createContext(defaultValue)

  function useTypedContext() {
    const value = useContext(Context)
    if (value === undefined) {
      throw new Error(`use${name} must be used within ${name}Provider`)
    }
    return value
  }

  return { Context, useTypedContext }
}

// --- Auth Context ---
// TS: interface AuthContextType { user: User | null; login: ...; logout: ... }
const { Context: AuthContext, useTypedContext: useAuth } = createTypedContext('Auth')

function AuthProvider(initialUser, children) {
  let user = initialUser || null
  let isLoading = false
  const listeners = new Set()

  const notify = () => listeners.forEach(fn => fn({ user, isLoading }))

  const value = {
    get user() { return user },
    get isLoading() { return isLoading },

    login(credentials) {
      isLoading = true
      notify()
      // Симуляция async
      setTimeout(() => {
        user = { id: 1, name: credentials.username, role: 'user' }
        isLoading = false
        notify()
      }, 50)
    },

    logout() {
      user = null
      notify()
    },

    subscribe(fn) {
      listeners.add(fn)
      return () => listeners.delete(fn)
    }
  }

  return AuthContext.Provider(value, children)
}

// --- Theme Context ---
const { Context: ThemeContext, useTypedContext: useTheme } = createTypedContext('Theme')

function ThemeProvider(initialTheme, children) {
  let theme = initialTheme || 'light'

  const value = {
    get theme() { return theme },
    toggleTheme() {
      theme = theme === 'light' ? 'dark' : 'light'
      console.log('  [ThemeContext] theme changed to:', theme)
    },
    setTheme(t) { theme = t }
  }

  return ThemeContext.Provider(value, children)
}

// --- Cart Context ---
const { Context: CartContext, useTypedContext: useCart } = createTypedContext('Cart')

function CartProvider(children) {
  const items = []

  const value = {
    get items() { return [...items] },
    get total() { return items.reduce((sum, item) => sum + item.price * item.quantity, 0) },
    addItem(item) {
      const existing = items.find(i => i.id === item.id)
      if (existing) {
        existing.quantity += item.quantity
      } else {
        items.push({ ...item })
      }
    },
    removeItem(id) {
      const idx = items.findIndex(i => i.id === id)
      if (idx >= 0) items.splice(idx, 1)
    },
    clear() { items.length = 0 }
  }

  return CartContext.Provider(value, children)
}

// --- Composition провайдеров (как AppProviders в React) ---
function AppProviders(children) {
  return AuthProvider(null,
    () => ThemeProvider('light',
      () => CartProvider(children)
    )
  )
}

// --- Демонстрация ---

console.log('=== Auth Context ===')
AppProviders(() => {
  const auth = useAuth()
  const theme = useTheme()
  const cart = useCart()

  console.log('initial user:', auth.user)
  console.log('initial theme:', theme.theme)

  auth.login({ username: 'Алексей', password: 'secret' })
  // user устанавливается через setTimeout, поэтому проверим позже

  console.log('\n=== Theme Context ===')
  console.log('current theme:', theme.theme)
  theme.toggleTheme()
  console.log('after toggle:', theme.theme)
  theme.toggleTheme()
  console.log('after second toggle:', theme.theme)

  console.log('\n=== Cart Context ===')
  cart.addItem({ id: 1, name: 'Товар 1', price: 100, quantity: 2 })
  cart.addItem({ id: 2, name: 'Товар 2', price: 250, quantity: 1 })
  cart.addItem({ id: 1, name: 'Товар 1', price: 100, quantity: 1 }) // добавляется к существующему

  console.log('items:', cart.items.map(i => `${i.name} x${i.quantity}`))
  console.log('total:', cart.total)  // 100*3 + 250*1 = 550

  cart.removeItem(2)
  console.log('after removeItem(2), items:', cart.items.length)
  console.log('after removeItem(2), total:', cart.total)  // 300
})

// --- Ошибка вне провайдера ---
console.log('\n=== Ошибка без провайдера ===')
try {
  useAuth()  // выбросит ошибку
} catch (e) {
  console.log('Ожидаемая ошибка:', e.message)
}