React Typescript

Context API: передавання даних без «пропс-дрілінгу»

Глибоке занурення в Context API для передавання даних через дерево компонентів без прокидання пропсів. Від простих тем до складних систем глобального стану.

Context API: передавання даних без «пропс-дрілінгу»

🔗 Офіційний референс: Передавання даних глибоко через Context (Passing Data Deeply with Context)

🎯 Мета лекції

  • Зрозуміти проблему «пропс-дрілінгу» (prop drilling) та обмеження чистого підйому стану.
  • Опанувати анатомію Context API: createContext, Provider, useContext.
  • Навчитися проєктувати контексти для різних сценаріїв: теми, аутентифікація, мова інтерфейсу.
  • Застосувати TypeScript для строгої типізації контекстів та уникнення помилок при відсутності провайдера.

🔑 Ключові терміни

  • Пропс-дрілінг (Prop Drilling): передавання пропсів через проміжні компоненти, які самі їх не використовують.
  • Context (Контекст): механізм React для передачі даних через дерево компонентів без явного прокидання пропсів.
  • Provider (Провайдер): компонент, що надає значення контексту всім нащадкам у піддереві.
  • Consumer (Споживач): компонент, що читає значення з контексту через хук useContext.

Проблема пропс-дрілінгу: коли підйом стану не масштабується

У попередніх лекціях ми розглянули патерн підйому стану (lifting state up) — переміщення спільного стану до найближчого спільного предка компонентів, які його потребують. Цей підхід працює чудово для невеликих піддерев, але швидко стає обтяжливим у великих застосунках.

Розглянемо типовий приклад: застосунок із системою тем (світла/темна). Стан теми зберігається у кореневому компоненті App, але потрібен глибоко вкладеним компонентам на кількох рівнях вниз:

interface Theme {
  mode: "light" | "dark";
  primaryColor: string;
  backgroundColor: string;
}

const lightTheme: Theme = {
  mode: "light",
  primaryColor: "#3b82f6",
  backgroundColor: "#ffffff",
};

const darkTheme: Theme = {
  mode: "dark",
  primaryColor: "#60a5fa",
  backgroundColor: "#1e293b",
};

function App() {
  const [theme, setTheme] = useState<Theme>(lightTheme);

  const toggleTheme = () => {
    setTheme((prev) => (prev.mode === "light" ? darkTheme : lightTheme));
  };

  return (
    <div>
      <Header theme={theme} onToggleTheme={toggleTheme} />
      <MainContent theme={theme} />
      <Footer theme={theme} />
    </div>
  );
}

function Header({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => void }) {
  return (
    <header style={{ backgroundColor: theme.backgroundColor }}>
      <Navigation theme={theme} />
      <ThemeToggleButton theme={theme} onToggle={onToggleTheme} />
    </header>
  );
}

function Navigation({ theme }: { theme: Theme }) {
  return (
    <nav>
      <NavLink theme={theme} to="/">Головна</NavLink>
      <NavLink theme={theme} to="/about">Про нас</NavLink>
    </nav>
  );
}

function NavLink({ theme, to, children }: { theme: Theme; to: string; children: React.ReactNode }) {
  return (
    <a href={to} style={{ color: theme.primaryColor }}>
      {children}
    </a>
  );
}

function ThemeToggleButton({ theme, onToggle }: { theme: Theme; onToggle: () => void }) {
  return (
    <button
      onClick={onToggle}
      style={{
        backgroundColor: theme.primaryColor,
        color: theme.backgroundColor,
      }}
    >
      {theme.mode === "light" ? "🌙 Темна" : "☀️ Світла"}
    </button>
  );
}

function MainContent({ theme }: { theme: Theme }) {
  return (
    <main style={{ backgroundColor: theme.backgroundColor }}>
      <Article theme={theme} />
      <Sidebar theme={theme} />
    </main>
  );
}

function Article({ theme }: { theme: Theme }) {
  return (
    <article>
      <ArticleHeader theme={theme} />
      <ArticleBody theme={theme} />
    </article>
  );
}

// ... і ще десятки компонентів, кожен із яких приймає theme як проп

Звернемо увагу на ключову проблему: компоненти Header, Navigation, MainContent, Article не використовують проп theme безпосередньо — вони лише передають його вниз до дочірніх компонентів. Це явище називається пропс-дрілінгом (prop drilling) — прокидання пропсів через проміжні шари, які самі їх не потребують.

Пропс-дрілінг створює кілька системних проблем:
  1. Крихкість коду: Додавання нового рівня вкладеності вимагає правки всіх проміжних компонентів.
  2. Складність рефакторингу: Зміна сигнатури пропса поширюється на десятки файлів.
  3. Зниження читабельності: Кожен компонент має пропси, які він не використовує, що засмічує інтерфейс.
  4. Імплементаційна залежність: Проміжні компоненти стають залежними від імплементаційних деталей своїх нащадків.

Анатомія Context API: три фундаментальні елементи

Context API вирішує проблему пропс-дрілінгу, дозволяючи «телепортувати» дані безпосередньо до компонентів, які їх потребують, обходячи всі проміжні рівні. Context складається з трьох базових блоків:

1. Створення контексту: createContext

Контекст створюється викликом функції React.createContext(), яка приймає значення за замовчуванням (default value):

import { createContext } from "react";

interface Theme {
  mode: "light" | "dark";
  primaryColor: string;
  backgroundColor: string;
}

const lightTheme: Theme = {
  mode: "light",
  primaryColor: "#3b82f6",
  backgroundColor: "#ffffff",
};

// Створення контексту з дефолтним значенням
const ThemeContext = createContext<Theme>(lightTheme);

Важливо: Значення за замовчуванням використовується лише тоді, коли компонент намагається прочитати контекст, але не знаходить відповідного Provider у дереві предків. У реальних застосунках це зазвичай є помилкою конфігурації, тому часто використовують createContext<Theme | undefined>(undefined) і перевіряють наявність значення явно.

Функція createContext повертає об'єкт із двома властивостями:
  • Context.Provider — компонент-провайдер.
  • Context.Consumer — застарілий компонент-споживач (замінений хуком useContext).

2. Надання значення: Context.Provider

Компонент Provider обгортає піддерево React-компонентів і надає їм доступ до значення контексту через проп value:

function App() {
  const [theme, setTheme] = useState<Theme>(lightTheme);

  return (
    <ThemeContext.Provider value={theme}>
      <Header />
      <MainContent />
      <Footer />
    </ThemeContext.Provider>
  );
}

Усі компоненти всередині <ThemeContext.Provider> — незалежно від рівня вкладеності — тепер можуть читати значення theme без явного прокидання пропсів.

Провайдери можна вкладати один в одного. Компонент завжди читає значення з найближчого провайдера вгору по дереву. Це дозволяє перевизначати контекст для окремих піддерев.

3. Читання значення: useContext

Хук useContext приймає об'єкт контексту (результат виклику createContext) і повертає поточне значення з найближчого Provider:

import { useContext } from "react";

function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
  const theme = useContext(ThemeContext);

  return (
    <a href={to} style={{ color: theme.primaryColor }}>
      {children}
    </a>
  );
}

Тепер компонент NavLink читає тему безпосередньо з контексту, і проміжні компоненти (App → Header → Navigation) не потребують прокидання пропса theme.

Якщо компонент викликає useContext(ThemeContext), але не знаходить відповідного <ThemeContext.Provider> у дереві предків, React поверне значення за замовчуванням, передане у createContext. Це може призвести до непередбачуваної поведінки, тому краще явно перевіряти наявність провайдера (див. розділ про типізацію).

Перший повний приклад: перемикач теми

Тепер об'єднаємо всі три елементи у повноцінний робочий приклад із перемикачем теми:

import { createContext, useContext, useState } from "react";

// 1. Визначення типів
interface Theme {
  mode: "light" | "dark";
  primaryColor: string;
  backgroundColor: string;
  textColor: string;
}

const lightTheme: Theme = {
  mode: "light",
  primaryColor: "#3b82f6",
  backgroundColor: "#ffffff",
  textColor: "#1e293b",
};

const darkTheme: Theme = {
  mode: "dark",
  primaryColor: "#60a5fa",
  backgroundColor: "#1e293b",
  textColor: "#f1f5f9",
};

// 2. Створення контексту
const ThemeContext = createContext<Theme>(lightTheme);

// 3. Провайдер із логікою перемикання
function App() {
  const [theme, setTheme] = useState<Theme>(lightTheme);

  const toggleTheme = () => {
    setTheme((prev) => (prev.mode === "light" ? darkTheme : lightTheme));
  };

  return (
    <ThemeContext.Provider value={theme}>
      <div
        style={{
          minHeight: "100vh",
          backgroundColor: theme.backgroundColor,
          color: theme.textColor,
          padding: "2rem",
        }}
      >
        <Header onToggleTheme={toggleTheme} />
        <MainContent />
      </div>
    </ThemeContext.Provider>
  );
}

// 4. Компоненти, які читають контекст
function Header({ onToggleTheme }: { onToggleTheme: () => void }) {
  return (
    <header>
      <Navigation />
      <ThemeToggleButton onToggle={onToggleTheme} />
    </header>
  );
}

function Navigation() {
  return (
    <nav style={{ display: "flex", gap: "1rem", marginBottom: "2rem" }}>
      <NavLink to="/">Головна</NavLink>
      <NavLink to="/about">Про нас</NavLink>
      <NavLink to="/contact">Контакти</NavLink>
    </nav>
  );
}

function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
  const theme = useContext(ThemeContext);

  return (
    <a
      href={to}
      style={{
        color: theme.primaryColor,
        textDecoration: "none",
        fontWeight: 600,
      }}
    >
      {children}
    </a>
  );
}

function ThemeToggleButton({ onToggle }: { onToggle: () => void }) {
  const theme = useContext(ThemeContext);

  return (
    <button
      onClick={onToggle}
      style={{
        padding: "0.5rem 1rem",
        backgroundColor: theme.primaryColor,
        color: theme.backgroundColor,
        border: "none",
        borderRadius: "0.5rem",
        fontWeight: 600,
        cursor: "pointer",
      }}
    >
      {theme.mode === "light" ? "🌙 Темна тема" : "☀️ Світла тема"}
    </button>
  );
}

function MainContent() {
  const theme = useContext(ThemeContext);

  return (
    <main>
      <h1 style={{ marginBottom: "1rem" }}>Вітаємо у застосунку з темами!</h1>
      <p style={{ lineHeight: "1.6" }}>
        Цей текст автоматично адаптується до обраної теми завдяки Context API.
        Жоден проміжний компонент не прокидає пропси — всі компоненти читають
        тему безпосередньо з контексту.
      </p>
    </main>
  );
}
React Перемикач теми через Context API
Файли проєкту
src
App.tsx
localhost:3000

Збирання React застосунку…

Ключові спостереження

Відсутність пропс-дрілінгу. Зверніть увагу: компоненти Header та Navigation більше не приймають проп theme. Вони виступають лише як структурні обгортки, а всі листові компоненти (NavLink, ThemeToggleButton, MainContent) читають тему безпосередньо з контексту.

Логіка перемикання залишається у App. Функція toggleTheme все ще розміщена у кореневому компоненті, оскільки саме тут зберігається стан. Context API не замінює управління станом — він лише вирішує проблему його розповсюдження.

Проста типізація. TypeScript автоматично виводить тип повернення useContext(ThemeContext) як Theme, забезпечуючи автодоповнення та перевірку типів.


Типізація контексту: захист від помилок відсутності провайдера

У попередньому прикладі ми передали значення за замовчуванням у createContext(lightTheme). Проте це створює проблему: якщо розробник забуде обгорнути компонент у <ThemeContext.Provider>, застосунок не впаде з помилкою, а просто використає дефолтне значення. Це приховує баги та ускладнює налагодження.

Професійний підхід — використовувати undefined як значення за замовчуванням і явно перевіряти наявність провайдера:

import { createContext, useContext, useState } from "react";

interface Theme {
  mode: "light" | "dark";
  primaryColor: string;
  backgroundColor: string;
  textColor: string;
}

// Контекст може бути undefined, якщо немає провайдера
const ThemeContext = createContext<Theme | undefined>(undefined);

// Кастомний хук із перевіркою наявності провайдера
function useTheme(): Theme {
  const context = useContext(ThemeContext);

  if (context === undefined) {
    throw new Error("useTheme має використовуватись всередині ThemeProvider");
  }

  return context;
}

// Компонент-провайдер із логікою
interface ThemeProviderProps {
  children: React.ReactNode;
}

function ThemeProvider({ children }: ThemeProviderProps) {
  const [theme, setTheme] = useState<Theme>({
    mode: "light",
    primaryColor: "#3b82f6",
    backgroundColor: "#ffffff",
    textColor: "#1e293b",
  });

  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  );
}

Тепер замість прямого виклику useContext(ThemeContext) компоненти використовують кастомний хук useTheme(), який гарантує наявність провайдера:

function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
  // Якщо провайдера немає, виникне чітка помилка
  const theme = useTheme();

  return (
    <a href={to} style={{ color: theme.primaryColor }}>
      {children}
    </a>
  );
}
Цей патерн є стандартом де-факто у великих проєктах:
  1. Створіть контекст із типом T | undefined.
  2. Напишіть кастомний хук useMyContext(), який перевіряє undefined і кидає помилку.
  3. Експортуйте лише хук та провайдер — сам контекст залишається приватним.
Це забезпечує fail-fast поведінку: помилки виявляються негайно при розробці, а не приховуються дефолтними значеннями.

Контекст із функціями: передавання логіки оновлення

Часто потрібно передавати не лише дані, а й функції для їхнього оновлення. Розширимо приклад теми, додавши функцію перемикання безпосередньо у контекст:

import { createContext, useContext, useState } from "react";

interface Theme {
  mode: "light" | "dark";
  primaryColor: string;
  backgroundColor: string;
  textColor: string;
}

interface ThemeContextValue {
  theme: Theme;
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

function useTheme(): ThemeContextValue {
  const context = useContext(ThemeContext);

  if (context === undefined) {
    throw new Error("useTheme має використовуватись всередині ThemeProvider");
  }

  return context;
}

const lightTheme: Theme = {
  mode: "light",
  primaryColor: "#3b82f6",
  backgroundColor: "#ffffff",
  textColor: "#1e293b",
};

const darkTheme: Theme = {
  mode: "dark",
  primaryColor: "#60a5fa",
  backgroundColor: "#1e293b",
  textColor: "#f1f5f9",
};

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>(lightTheme);

  const toggleTheme = () => {
    setTheme((prev) => (prev.mode === "light" ? darkTheme : lightTheme));
  };

  const value: ThemeContextValue = {
    theme,
    toggleTheme,
  };

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

// Експорт лише хука та провайдера
export { ThemeProvider, useTheme };

Тепер компоненти можуть як читати тему, так і перемикати її без прокидання callback-функцій:

function ThemeToggleButton() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button onClick={toggleTheme}>
      {theme.mode === "light" ? "🌙 Темна тема" : "☀️ Світла тема"}
    </button>
  );
}

function App() {
  return (
    <ThemeProvider>
      <div>
        <Header />
        <MainContent />
      </div>
    </ThemeProvider>
  );
}
Компонент App тепер взагалі не знає про існування логіки перемикання теми. Уся логіка інкапсульована у ThemeProvider, а компоненти взаємодіють із нею через простий API хука useTheme().

Множинні контексти: композиція незалежних провайдерів

У реальних застосунках часто потрібно кілька незалежних контекстів: тема, мова інтерфейсу, дані аутентифікованого користувача тощо. Контексти можна вкладати один в одного:

import { createContext, useContext, useState } from "react";

// ─── Theme Context ────────────────────────────────────────────────────────

interface Theme {
  mode: "light" | "dark";
  primaryColor: string;
}

const ThemeContext = createContext<
  { theme: Theme; toggleTheme: () => void } | undefined
>(undefined);

function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error("useTheme requires ThemeProvider");
  return context;
}

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>({
    mode: "light",
    primaryColor: "#3b82f6",
  });

  const toggleTheme = () => {
    setTheme((prev) => ({
      ...prev,
      mode: prev.mode === "light" ? "dark" : "light",
    }));
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// ─── Language Context ─────────────────────────────────────────────────────

type Language = "uk" | "en";

interface LanguageContextValue {
  language: Language;
  setLanguage: (lang: Language) => void;
  t: (key: string) => string;
}

const LanguageContext = createContext<LanguageContextValue | undefined>(undefined);

function useLanguage() {
  const context = useContext(LanguageContext);
  if (!context) throw new Error("useLanguage requires LanguageProvider");
  return context;
}

const translations: Record<Language, Record<string, string>> = {
  uk: {
    welcome: "Вітаємо",
    settings: "Налаштування",
  },
  en: {
    welcome: "Welcome",
    settings: "Settings",
  },
};

function LanguageProvider({ children }: { children: React.ReactNode }) {
  const [language, setLanguage] = useState<Language>("uk");

  const t = (key: string): string => {
    return translations[language][key] || key;
  };

  return (
    <LanguageContext.Provider value={{ language, setLanguage, t }}>
      {children}
    </LanguageContext.Provider>
  );
}

// ─── App Composition ──────────────────────────────────────────────────────

function App() {
  return (
    <ThemeProvider>
      <LanguageProvider>
        <MainApp />
      </LanguageProvider>
    </ThemeProvider>
  );
}

function MainApp() {
  const { theme } = useTheme();
  const { t } = useLanguage();

  return (
    <div style={{ backgroundColor: theme.mode === "dark" ? "#1e293b" : "#fff" }}>
      <h1>{t("welcome")}</h1>
      <Settings />
    </div>
  );
}

function Settings() {
  const { toggleTheme } = useTheme();
  const { language, setLanguage, t } = useLanguage();

  return (
    <div>
      <h2>{t("settings")}</h2>
      <button onClick={toggleTheme}>Toggle Theme</button>
      <button onClick={() => setLanguage(language === "uk" ? "en" : "uk")}>
        Switch to {language === "uk" ? "EN" : "UK"}
      </button>
    </div>
  );
}

Ключові спостереження:

  1. Незалежність контекстів. Кожен провайдер управляє власним станом. Зміна теми не викликає повторного рендеру компонентів, які читають лише мову, і навпаки.
  2. Композиція провайдерів. Вкладання <ThemeProvider><LanguageProvider>...</LanguageProvider></ThemeProvider> створює окремі контексти, доступні на різних рівнях дерева.
  3. Чіткий API. Кожен контекст експортує лише хук (useTheme, useLanguage) та провайдер. Внутрішні деталі прихован.
Уникайте надмірної вкладеності провайдерів у кореневому компоненті. Якщо їх більше 5-7, розгляньте можливість створення одного агрегованого провайдера або використання бібліотек управління станом (Redux, Zustand).

Context + useReducer: глобальне управління складним станом

Для складних станів із множинними переходами Context API природно поєднується з хуком useReducer. Цей патерн дозволяє централізувати як логіку стану, так і його доступність.

Розглянемо систему сповіщень (notifications), де будь-який компонент може додавати, видаляти або очищати сповіщення:

import { createContext, useContext, useReducer } from "react";

// ─── Types ────────────────────────────────────────────────────────────────

interface Notification {
  id: string;
  message: string;
  type: "info" | "success" | "warning" | "error";
}

type NotificationAction =
  | { type: "ADD_NOTIFICATION"; notification: Notification }
  | { type: "REMOVE_NOTIFICATION"; id: string }
  | { type: "CLEAR_ALL" };

interface NotificationContextValue {
  notifications: Notification[];
  addNotification: (message: string, type: Notification["type"]) => void;
  removeNotification: (id: string) => void;
  clearAll: () => void;
}

// ─── Reducer ──────────────────────────────────────────────────────────────

function notificationReducer(
  state: Notification[],
  action: NotificationAction
): Notification[] {
  switch (action.type) {
    case "ADD_NOTIFICATION":
      return [...state, action.notification];

    case "REMOVE_NOTIFICATION":
      return state.filter((n) => n.id !== action.id);

    case "CLEAR_ALL":
      return [];

    default:
      return state;
  }
}

// ─── Context ──────────────────────────────────────────────────────────────

const NotificationContext = createContext<NotificationContextValue | undefined>(
  undefined
);

function useNotifications(): NotificationContextValue {
  const context = useContext(NotificationContext);

  if (context === undefined) {
    throw new Error(
      "useNotifications має використовуватись всередині NotificationProvider"
    );
  }

  return context;
}

// ─── Provider ─────────────────────────────────────────────────────────────

function NotificationProvider({ children }: { children: React.ReactNode }) {
  const [notifications, dispatch] = useReducer(notificationReducer, []);

  const addNotification = (message: string, type: Notification["type"]) => {
    const id = Date.now().toString() + Math.random().toString(36);
    dispatch({
      type: "ADD_NOTIFICATION",
      notification: { id, message, type },
    });

    // Автоматичне видалення через 5 секунд
    setTimeout(() => {
      dispatch({ type: "REMOVE_NOTIFICATION", id });
    }, 5000);
  };

  const removeNotification = (id: string) => {
    dispatch({ type: "REMOVE_NOTIFICATION", id });
  };

  const clearAll = () => {
    dispatch({ type: "CLEAR_ALL" });
  };

  const value: NotificationContextValue = {
    notifications,
    addNotification,
    removeNotification,
    clearAll,
  };

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

// ─── UI Components ────────────────────────────────────────────────────────

function NotificationList() {
  const { notifications, removeNotification } = useNotifications();

  if (notifications.length === 0) return null;

  return (
    <div
      style={{
        position: "fixed",
        top: "1rem",
        right: "1rem",
        zIndex: 1000,
        display: "flex",
        flexDirection: "column",
        gap: "0.5rem",
      }}
    >
      {notifications.map((notification) => (
        <div
          key={notification.id}
          style={{
            padding: "1rem",
            borderRadius: "0.5rem",
            backgroundColor:
              notification.type === "error"
                ? "#ef4444"
                : notification.type === "success"
                ? "#22c55e"
                : notification.type === "warning"
                ? "#f59e0b"
                : "#3b82f6",
            color: "white",
            minWidth: "300px",
            boxShadow: "0 4px 6px rgba(0,0,0,0.1)",
          }}
        >
          <div style={{ display: "flex", justifyContent: "space-between" }}>
            <span>{notification.message}</span>
            <button
              onClick={() => removeNotification(notification.id)}
              style={{
                background: "none",
                border: "none",
                color: "white",
                cursor: "pointer",
                fontSize: "1.2rem",
              }}
            >
              ✕
            </button>
          </div>
        </div>
      ))}
    </div>
  );
}

function DemoButtons() {
  const { addNotification, clearAll } = useNotifications();

  return (
    <div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
      <button onClick={() => addNotification("Операція успішна", "success")}>
        Success
      </button>
      <button onClick={() => addNotification("Це інформація", "info")}>
        Info
      </button>
      <button onClick={() => addNotification("Увага!", "warning")}>
        Warning
      </button>
      <button onClick={() => addNotification("Помилка виконання", "error")}>
        Error
      </button>
      <button onClick={clearAll}>Очистити всі</button>
    </div>
  );
}

function App() {
  return (
    <NotificationProvider>
      <div style={{ padding: "2rem" }}>
        <h1>Система сповіщень</h1>
        <DemoButtons />
        <NotificationList />
      </div>
    </NotificationProvider>
  );
}
React Context API + useReducer: система сповіщень
Файли проєкту
src
App.tsx
localhost:3000

Збирання React застосунку…

Розбір архітектури

Централізація логіки. Весь стан сповіщень і логіка їхньої обробки інкапсульовані у NotificationProvider. Компоненти не знають про редюсер — вони взаємодіють лише через простий API хука useNotifications().

Побічні ефекти у провайдері. Функція addNotification не лише диспатчить екшен, а й запускає таймер для автоматичного видалення сповіщення. Це приклад того, як провайдер може містити побічні ефекти, які не підходять для редюсера.

Глобальна доступність. Будь-який компонент усередині <NotificationProvider> може викликати useNotifications() і отримати доступ до всіх функцій. Це робить систему сповіщень повністю незалежною від структури дерева компонентів.

Цей патерн (Context + useReducer + кастомний хук) є мінімалістичною альтернативою Redux для невеликих та середніх застосунків. Він забезпечує централізоване управління станом без додаткових бібліотек.

Оптимізація продуктивності: уникнення зайвих рендерів

Context API має одну важливу особливість: будь-яка зміна значення у Provider викликає повторний рендер всіх компонентів, які читають цей контекст через useContext, навіть якщо вони використовують лише частину даних.

Розглянемо проблему та способи її вирішення:

interface AppState {
  user: { name: string; email: string };
  theme: { mode: "light" | "dark" };
  settings: { language: string; notifications: boolean };
}

const AppContext = createContext<AppState | undefined>(undefined);

function AppProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState<AppState>({
    user: { name: "Олена", email: "olena@example.com" },
    theme: { mode: "light" },
    settings: { language: "uk", notifications: true },
  });

  return <AppContext.Provider value={state}>{children}</AppContext.Provider>;
}

// Проблема: цей компонент рендериться при зміні будь-якої частини стану
function UserName() {
  const state = useContext(AppContext);
  console.log("UserName re-rendered"); // Викликається навіть при зміні theme!

  return <span>{state?.user.name}</span>;
}

Рішення 1: Розділення контекстів

Найпростіше рішення — розділити великий контекст на кілька незалежних:

const UserContext = createContext<User | undefined>(undefined);
const ThemeContext = createContext<Theme | undefined>(undefined);
const SettingsContext = createContext<Settings | undefined>(undefined);

function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User>(...);
  const [theme, setTheme] = useState<Theme>(...);
  const [settings, setSettings] = useState<Settings>(...);

  return (
    <UserContext.Provider value={user}>
      <ThemeContext.Provider value={theme}>
        <SettingsContext.Provider value={settings}>
          {children}
        </SettingsContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

// Тепер цей компонент рендериться лише при зміні user
function UserName() {
  const user = useContext(UserContext);
  return <span>{user?.name}</span>;
}

Рішення 2: Розділення даних та функцій

Якщо контекст містить як дані, так і функції оновлення, розділіть їх на два окремі контексти:

const ThemeStateContext = createContext<Theme | undefined>(undefined);
const ThemeDispatchContext = createContext<(() => void) | undefined>(undefined);

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>(lightTheme);

  const toggleTheme = () => {
    setTheme((prev) => (prev.mode === "light" ? darkTheme : lightTheme));
  };

  return (
    <ThemeStateContext.Provider value={theme}>
      <ThemeDispatchContext.Provider value={toggleTheme}>
        {children}
      </ThemeDispatchContext.Provider>
    </ThemeStateContext.Provider>
  );
}

// Компонент, який лише читає тему — рендериться при зміні theme
function ThemedButton() {
  const theme = useContext(ThemeStateContext);
  return <button style={{ color: theme?.primaryColor }}>Кнопка</button>;
}

// Компонент, який лише перемикає тему — НЕ рендериться при зміні theme
function ThemeToggle() {
  const toggleTheme = useContext(ThemeDispatchContext);
  return <button onClick={toggleTheme}>Toggle</button>;
}
Функція toggleTheme має стабільний референс між рендерами (якщо створена через useCallback або винесена з провайдера), тому компоненти, які читають лише ThemeDispatchContext, не рендеряться при зміні самої теми.

Рішення 3: useMemo для стабілізації об'єктів

Якщо значення контексту — це об'єкт, обгорніть його у useMemo, щоб уникнути створення нового об'єкта при кожному рендері провайдера:

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>(lightTheme);

  const toggleTheme = useCallback(() => {
    setTheme((prev) => (prev.mode === "light" ? darkTheme : lightTheme));
  }, []);

  const value = useMemo(
    () => ({ theme, toggleTheme }),
    [theme, toggleTheme]
  );

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}
Не оптимізуйте передчасно. Розділення контекстів та мемоїзація мають сенс лише у випадках, коли:
  1. Контекст змінюється часто (наприклад, позиція курсора миші).
  2. Піддерево споживачів велике (сотні компонентів).
  3. Профілювання показало реальну проблему продуктивності.
Для більшості застосунків базовий Context API працює достатньо швидко без додаткових оптимізацій.

Context API vs Redux: коли використовувати кожен

КритерійContext APIRedux / Zustand
Складність налаштуванняМінімальна (вбудовано у React)Потребує додаткових бібліотек
Обсяг boilerplate-кодуНизькийСередній (Redux) / Низький (Zustand)
Оптимізація рендерівРучна (розділення контекстів)Автоматична (селектори)
DevToolsНемає вбудованихRedux DevTools (Time Travel)
MiddlewareВідсутніЛогування, Thunks, Sagas
Рекомендовано дляЛокальний стан піддерев, теми, i18nГлобальний стан застосунку, складна синхронізація
Загальне правило:
  • Використовуйте Context API для інфраструктурних даних, які рідко змінюються: тема, мова, дані аутентифікації, конфігурація.
  • Використовуйте Redux/Zustand для доменних даних, які часто змінюються: список товарів, дані форм, кешовані API-відповіді.
Багато застосунків успішно поєднують обидва підходи: Context для інфраструктури + Redux для бізнес-логіки.

Практичні завдання


Запитання для самоконтролю


Підсумок

Context API — це потужний вбудований механізм React для передавання даних через дерево компонентів без прокидання пропсів. Ключові висновки цієї лекції:

🚫 Вирішення пропс-дрілінгу

Context дозволяє «телепортувати» дані безпосередньо до компонентів, які їх потребують, обходячи всі проміжні рівні. Це усуває необхідність прокидати пропси через компоненти, які самі їх не використовують.

🔒 Типобезпека через TypeScript

Використання undefined як дефолтного значення та кастомних хуків із перевіркою забезпечує fail-fast поведінку — помилки виявляються негайно, а не приховуються.

🔄 Композиція з useReducer

Поєднання Context + useReducer створює мінімалістичну альтернативу Redux для локального управління складним станом без додаткових бібліотек.

⚡ Оптимізація продуктивності

Розділення великих контекстів на незалежні частини та відокремлення даних від функцій оновлення запобігає зайвим рендерам споживачів.

Коли використовувати Context API

✅ Ідеально підходить для:

  • Системи тем (світла/темна)
  • Мультимовність (i18n)
  • Дані аутентифікованого користувача
  • Глобальні UI-налаштування (розмір шрифту, контрастність)
  • Системи сповіщень та модальних вікон

⚠️ Розгляньте альтернативи для:

  • Часто змінюваних даних (наприклад, позиція курсора миші)
  • Великих колекцій даних із складною синхронізацією
  • Необхідності middleware, DevTools або time-travel debugging
У наступних лекціях ми розглянемо, як інтегрувати Context API з асинхронною логікою, побічними ефектами через useEffect, та бібліотеки управління станом (Redux, Zustand) для випадків, коли Context API досягає своїх меж.

Додаткові ресурси

Офіційна документація
посилання
Passing Data Deeply with Context — повний посібник від команди React з інтерактивними прикладами.
Scaling Up with Reducer and Context
посилання
Scaling Up with Reducer and Context — комбінація useReducer та Context для складних сценаріїв.
TypeScript Handbook
посилання
Generics — розуміння дженериків допомагає правильно типізувати createContext<T>.
use-context-selector
бібліотека
Бібліотека для оптимізації Context API через селектори (запобігає зайвим рендерам без розділення контекстів).
Copyright © 2026