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) — прокидання пропсів через проміжні шари, які самі їх не потребують.
- Крихкість коду: Додавання нового рівня вкладеності вимагає правки всіх проміжних компонентів.
- Складність рефакторингу: Зміна сигнатури пропса поширюється на десятки файлів.
- Зниження читабельності: Кожен компонент має пропси, які він не використовує, що засмічує інтерфейс.
- Імплементаційна залежність: Проміжні компоненти стають залежними від імплементаційних деталей своїх нащадків.
Анатомія 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 застосунку…
Ключові спостереження
Відсутність пропс-дрілінгу. Зверніть увагу: компоненти 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>
);
}
- Створіть контекст із типом
T | undefined. - Напишіть кастомний хук
useMyContext(), який перевіряєundefinedі кидає помилку. - Експортуйте лише хук та провайдер — сам контекст залишається приватним.
Контекст із функціями: передавання логіки оновлення
Часто потрібно передавати не лише дані, а й функції для їхнього оновлення. Розширимо приклад теми, додавши функцію перемикання безпосередньо у контекст:
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>
);
}
Ключові спостереження:
- Незалежність контекстів. Кожен провайдер управляє власним станом. Зміна теми не викликає повторного рендеру компонентів, які читають лише мову, і навпаки.
- Композиція провайдерів. Вкладання
<ThemeProvider><LanguageProvider>...</LanguageProvider></ThemeProvider>створює окремі контексти, доступні на різних рівнях дерева. - Чіткий API. Кожен контекст експортує лише хук (
useTheme,useLanguage) та провайдер. Внутрішні деталі прихован.
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 застосунку…
Розбір архітектури
Централізація логіки. Весь стан сповіщень і логіка їхньої обробки інкапсульовані у NotificationProvider. Компоненти не знають про редюсер — вони взаємодіють лише через простий API хука useNotifications().
Побічні ефекти у провайдері. Функція addNotification не лише диспатчить екшен, а й запускає таймер для автоматичного видалення сповіщення. Це приклад того, як провайдер може містити побічні ефекти, які не підходять для редюсера.
Глобальна доступність. Будь-який компонент усередині <NotificationProvider> може викликати useNotifications() і отримати доступ до всіх функцій. Це робить систему сповіщень повністю незалежною від структури дерева компонентів.
Оптимізація продуктивності: уникнення зайвих рендерів
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>
);
}
- Контекст змінюється часто (наприклад, позиція курсора миші).
- Піддерево споживачів велике (сотні компонентів).
- Профілювання показало реальну проблему продуктивності.
Context API vs Redux: коли використовувати кожен
| Критерій | Context API | Redux / Zustand |
|---|---|---|
| Складність налаштування | Мінімальна (вбудовано у React) | Потребує додаткових бібліотек |
| Обсяг boilerplate-коду | Низький | Середній (Redux) / Низький (Zustand) |
| Оптимізація рендерів | Ручна (розділення контекстів) | Автоматична (селектори) |
| DevTools | Немає вбудованих | Redux DevTools (Time Travel) |
| Middleware | Відсутні | Логування, Thunks, Sagas |
| Рекомендовано для | Локальний стан піддерев, теми, i18n | Глобальний стан застосунку, складна синхронізація |
- Використовуйте Context API для інфраструктурних даних, які рідко змінюються: тема, мова, дані аутентифікації, конфігурація.
- Використовуйте Redux/Zustand для доменних даних, які часто змінюються: список товарів, дані форм, кешовані API-відповіді.
Практичні завдання
Створіть AuthContext із наступним функціоналом:
- Стан користувача:
{ id: string; username: string; role: "admin" | "user" } | null. - Функція
login(username: string, password: string): імітує вхід (просто встановлює користувача). - Функція
logout(): очищає дані користувача. - Компонент
ProtectedRoute: показує вміст лише для аутентифікованих користувачів, інакше — повідомлення «Увійдіть до системи».
Вимоги:
- Використовуйте кастомний хук
useAuth()із перевіркою провайдера. - Типізуйте всі значення через TypeScript.
- Додайте компонент
LoginFormіз двома інпутами та кнопкою.
import { createContext, useContext, useState } from "react";
interface User {
id: string;
username: string;
role: "admin" | "user";
}
interface AuthContextValue {
user: User | null;
login: (username: string, password: string) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth має використовуватись всередині AuthProvider");
}
return context;
}
function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = (username: string, password: string) => {
// Імітація API-запиту
if (password === "password") {
setUser({
id: "1",
username,
role: username === "admin" ? "admin" : "user",
});
} else {
alert("Невірний пароль");
}
};
const logout = () => {
setUser(null);
};
const value: AuthContextValue = { user, login, logout };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
function LoginForm() {
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
login(username, password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Ім'я користувача"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="password"
placeholder="Пароль (підказка: password)"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Увійти</button>
</form>
);
}
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
if (!user) {
return (
<div>
<p>Увійдіть до системи для доступу до цього контенту.</p>
<LoginForm />
</div>
);
}
return <>{children}</>;
}
function Dashboard() {
const { user, logout } = useAuth();
return (
<div>
<h2>Вітаємо, {user?.username}!</h2>
<p>Роль: {user?.role}</p>
<button onClick={logout}>Вийти</button>
</div>
);
}
function App() {
return (
<AuthProvider>
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
</AuthProvider>
);
}
Покроковий аналіз:
- Типізація контексту:
AuthContextValueчітко описує API, який отримують компоненти черезuseAuth(). - Перевірка провайдера: Хук
useAuth()кидає помилку, якщо контекстundefined, захищаючи від неправильного використання. - ProtectedRoute: Умовно рендерить вміст залежно від наявності користувача. Це типовий патерн для захисту маршрутів.
- Імітація входу: Функція
loginне виконує реальних API-запитів, але демонструє, де вони мають бути розміщені.
Створіть CartContext для інтернет-магазину:
- Стан: масив товарів
{ id: number; name: string; price: number; quantity: number }[]. - Функції:
addItem(item): додає товар або збільшує кількість.removeItem(id): видаляє товар.updateQuantity(id, quantity): змінює кількість.clearCart(): очищає корзину.totalPrice: обчислювана властивість (сума всіх товарів).
Вимоги:
- Використовуйте
useReducerдля управління станом корзини. - Створіть компонент
CartSummary: показує кількість товарів та загальну суму. - Додайте компонент
ProductListіз кнопками «Додати до корзини».
import { createContext, useContext, useReducer, useMemo } from "react";
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
type CartAction =
| { type: "ADD_ITEM"; item: Omit<CartItem, "quantity"> }
| { type: "REMOVE_ITEM"; id: number }
| { type: "UPDATE_QUANTITY"; id: number; quantity: number }
| { type: "CLEAR_CART" };
interface CartContextValue {
items: CartItem[];
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (id: number) => void;
updateQuantity: (id: number, quantity: number) => void;
clearCart: () => void;
totalPrice: number;
totalItems: number;
}
function cartReducer(state: CartItem[], action: CartAction): CartItem[] {
switch (action.type) {
case "ADD_ITEM": {
const existing = state.find((item) => item.id === action.item.id);
if (existing) {
return state.map((item) =>
item.id === action.item.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...state, { ...action.item, quantity: 1 }];
}
case "REMOVE_ITEM":
return state.filter((item) => item.id !== action.id);
case "UPDATE_QUANTITY":
return state.map((item) =>
item.id === action.id ? { ...item, quantity: action.quantity } : item
);
case "CLEAR_CART":
return [];
default:
return state;
}
}
const CartContext = createContext<CartContextValue | undefined>(undefined);
function useCart(): CartContextValue {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error("useCart має використовуватись всередині CartProvider");
}
return context;
}
function CartProvider({ children }: { children: React.ReactNode }) {
const [items, dispatch] = useReducer(cartReducer, []);
const addItem = (item: Omit<CartItem, "quantity">) => {
dispatch({ type: "ADD_ITEM", item });
};
const removeItem = (id: number) => {
dispatch({ type: "REMOVE_ITEM", id });
};
const updateQuantity = (id: number, quantity: number) => {
if (quantity <= 0) {
removeItem(id);
} else {
dispatch({ type: "UPDATE_QUANTITY", id, quantity });
}
};
const clearCart = () => {
dispatch({ type: "CLEAR_CART" });
};
const totalPrice = useMemo(
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
[items]
);
const totalItems = useMemo(
() => items.reduce((sum, item) => sum + item.quantity, 0),
[items]
);
const value: CartContextValue = {
items,
addItem,
removeItem,
updateQuantity,
clearCart,
totalPrice,
totalItems,
};
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
const products = [
{ id: 1, name: "Ноутбук", price: 25000 },
{ id: 2, name: "Миша", price: 500 },
{ id: 3, name: "Клавіатура", price: 1200 },
];
function ProductList() {
const { addItem } = useCart();
return (
<div>
<h2>Товари</h2>
{products.map((product) => (
<div key={product.id}>
<span>
{product.name} — {product.price} грн
</span>
<button onClick={() => addItem(product)}>Додати</button>
</div>
))}
</div>
);
}
function CartSummary() {
const { items, removeItem, updateQuantity, clearCart, totalPrice, totalItems } =
useCart();
if (items.length === 0) {
return <p>Корзина порожня</p>;
}
return (
<div>
<h2>Корзина ({totalItems} шт.)</h2>
{items.map((item) => (
<div key={item.id}>
<span>{item.name}</span>
<input
type="number"
value={item.quantity}
onChange={(e) => updateQuantity(item.id, Number(e.target.value))}
min="0"
/>
<button onClick={() => removeItem(item.id)}>Видалити</button>
</div>
))}
<p>Загалом: {totalPrice} грн</p>
<button onClick={clearCart}>Очистити корзину</button>
</div>
);
}
function App() {
return (
<CartProvider>
<ProductList />
<CartSummary />
</CartProvider>
);
}
Покроковий аналіз:
- Редюсер для корзини: Обробляє додавання (з перевіркою на дублікати), видалення, оновлення кількості та очищення.
- useMemo для обчислюваних властивостей:
totalPriceтаtotalItemsперераховуються лише при змініitems, уникаючи зайвих обчислень. - Абстракція через хук: Компоненти взаємодіють із корзиною лише через
useCart(), не знаючи про внутрішню реалізацію через редюсер.
Запитання для самоконтролю
Context API вирішує лише одну проблему — передавання даних через дерево компонентів. Він не надає:
- Оптимізованих селекторів: Redux дозволяє компонентам підписуватись на конкретні частини стану. Якщо компонент читає
state.user.name, він не рендериться при змініstate.theme. У Context API будь-яка зміна значення провайдера викликає рендер усіх споживачів. - Middleware: Redux підтримує middleware для логування, асинхронних операцій (thunks, sagas), персистенції стану. У Context API це доводиться реалізовувати вручну.
- DevTools: Redux DevTools дозволяє переглядати історію всіх екшенів, подорожувати у часі (time travel), експортувати та імпортувати стан. Context API не має вбудованих інструментів налагодження.
- Нормалізації даних: Redux Toolkit надає
createEntityAdapterдля ефективного управління колекціями. У Context API це треба писати самостійно.
Context API ідеальний для інфраструктурних даних (тема, мова, аутентифікація), які рідко змінюються та читаються багатьма компонентами. Redux краще підходить для доменних даних (списки, форми, кеш API), які часто оновлюються та потребують складної синхронізації.
Залежить від того, яке значення за замовчуванням ви передали у createContext:
Варіант 1: Явне значення за замовчуванням
const ThemeContext = createContext<Theme>(lightTheme);
function Button() {
const theme = useContext(ThemeContext); // Поверне lightTheme
return <button style={{ color: theme.primaryColor }}>Click</button>;
}
Компонент працюватиме, але використовуватиме дефолтне значення. Це може приховати помилку конфігурації — розробник не дізнається, що забув обгорнути дерево у провайдер.
Варіант 2: undefined як значення за замовчуванням (рекомендовано)
const ThemeContext = createContext<Theme | undefined>(undefined);
function useTheme(): Theme {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme має використовуватись всередині ThemeProvider");
}
return context;
}
Тепер спроба використати контекст без провайдера викличе чітку помилку негайно, вказуючи на проблему.
undefined як дефолт для контекстів, значення яких критичні для роботи застосунку. Явні помилки краще, ніж приховані баги з дефолтними значеннями.Так, це поширений патерн. Контекст може містити як дані, так і функції для їхнього оновлення:
interface CounterContextValue {
count: number;
increment: () => void;
decrement: () => void;
}
const CounterContext = createContext<CounterContextValue | undefined>(undefined);
function CounterProvider({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
const value: CounterContextValue = { count, increment, decrement };
return (
<CounterContext.Provider value={value}>
{children}
</CounterContext.Provider>
);
}
function CounterButton() {
const { increment } = useCounter();
return <button onClick={increment}>+1</button>;
}
Ключовий момент: стан зберігається у провайдері (кореневий компонент), але функції оновлення доступні будь-якому нащадку через контекст. Це дозволяє дочірнім компонентам «повідомляти» провайдеру про необхідність змін, не прокидаючи callback через всі проміжні рівні.
Існує кілька підходів:
1. Вкладання у кореневому компоненті (до 5-7 контекстів):
function App() {
return (
<AuthProvider>
<ThemeProvider>
<LanguageProvider>
<NotificationProvider>
<Routes />
</NotificationProvider>
</LanguageProvider>
</ThemeProvider>
</AuthProvider>
);
}
2. Композитний провайдер (більше 7 контекстів):
function AppProviders({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<ThemeProvider>
<LanguageProvider>
<NotificationProvider>
{children}
</NotificationProvider>
</LanguageProvider>
</ThemeProvider>
</AuthProvider>
);
}
function App() {
return (
<AppProviders>
<Routes />
</AppProviders>
);
}
3. Селективні провайдери на рівні маршрутів:
function App() {
return (
<AuthProvider>
<ThemeProvider>
<Routes>
<Route path="/admin" element={
<AdminProvider>
<AdminDashboard />
</AdminProvider>
} />
<Route path="/shop" element={
<CartProvider>
<Shop />
</CartProvider>
} />
</Routes>
</ThemeProvider>
</AuthProvider>
);
}
Глобальні контексти (тема, мова, auth) розміщуються на верхньому рівні, а специфічні для розділів (корзина, налаштування адмін-панелі) — локально всередині відповідних маршрутів.
Технічно так, але це антипатерн:
// ❌ ПОГАНА ПРАКТИКА
const StateContext = createContext<[number, React.Dispatch<React.SetStateAction<number>>] | undefined>(undefined);
function StateProvider({ children }: { children: React.ReactNode }) {
const state = useState(0);
return <StateContext.Provider value={state}>{children}</StateContext.Provider>;
}
function Counter() {
const [count, setCount] = useContext(StateContext)!;
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Проблеми:
- Складна типізація: Тип
[T, Dispatch<SetStateAction<T>>]важко читати та підтримувати. - Відсутність інкапсуляції: Компоненти отримують прямий доступ до
setCount, що може призвести до порушення бізнес-логіки. - Немає семантики: Неясно, які операції дозволені зі станом.
Краще рішення:
// ✅ ГАРНА ПРАКТИКА
interface CounterContextValue {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
const CounterContext = createContext<CounterContextValue | undefined>(undefined);
function CounterProvider({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
const value: CounterContextValue = {
count,
increment: () => setCount(c => c + 1),
decrement: () => setCount(c => c - 1),
reset: () => setCount(0),
};
return <CounterContext.Provider value={value}>{children}</CounterContext.Provider>;
}
Такий підхід забезпечує контрольований API — компоненти можуть виконувати лише дозволені операції, а внутрішня реалізація прихована.
Підсумок
Context API — це потужний вбудований механізм React для передавання даних через дерево компонентів без прокидання пропсів. Ключові висновки цієї лекції:
🚫 Вирішення пропс-дрілінгу
🔒 Типобезпека через TypeScript
undefined як дефолтного значення та кастомних хуків із перевіркою забезпечує fail-fast поведінку — помилки виявляються негайно, а не приховуються.🔄 Композиція з useReducer
⚡ Оптимізація продуктивності
Коли використовувати Context API
✅ Ідеально підходить для:
- Системи тем (світла/темна)
- Мультимовність (i18n)
- Дані аутентифікованого користувача
- Глобальні UI-налаштування (розмір шрифту, контрастність)
- Системи сповіщень та модальних вікон
⚠️ Розгляньте альтернативи для:
- Часто змінюваних даних (наприклад, позиція курсора миші)
- Великих колекцій даних із складною синхронізацією
- Необхідності middleware, DevTools або time-travel debugging
useEffect, та бібліотеки управління станом (Redux, Zustand) для випадків, коли Context API досягає своїх меж.Додаткові ресурси
createContext<T>.Хук useReducer: структуроване управління складним станом
Глибоке занурення в хук useReducer для керування складним станом у React через чіткі екшени та передбачувану логіку оновлень. Від простих лічильників до багаторівневих форм.
Синхронізація з ефектами: хук useEffect та функції очищення
Опануйте хук useEffect для підключення React-компонентів до зовнішніх систем: мережевих API, таймерів браузера, DOM-подій та сторонніх бібліотек. Від базових прикладів до складних патернів очищення.