Sometimes you don’t need Redux—just a small, type‑safe way to share a value or service across components without prop‑drilling. That’s exactly what React Context is for. Below is a clean, error‑free, copy‑paste‑ready setup with thorough explanations and commented code.
🧩 Core Ideas
- Context: A container that can provide a value deep in the tree.
- Provider: A component that owns the value and makes it available to its descendants.
- useContext: A hook to read that value.
- Dependency Injection (DI): Pass a service (e.g., API client) via Context so components can swap implementations (e.g., mock vs real).
1) Theme Context (with safe typing + memo + persistence)
A minimal, production‑ready pattern:
- Throws a helpful error if used outside its provider.
- Memoizes the context value to avoid unnecessary re‑renders.
- Persists the theme to
localStorageand respects system preference on first load.
// src/contexts/ThemeContext.tsx
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
// 1) Value shape for this context
export type Theme = 'light' | 'dark';
export type ThemeContextValue = {
theme: Theme; // current theme
setTheme: (t: Theme) => void; // explicit setter
toggle: () => void; // convenience action
};
// 2) Context (default is undefined so we can catch misuse at runtime)
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function getInitialTheme(): Theme {
// SSR/Non‑DOM guard
if (typeof window === 'undefined') return 'light';
// localStorage wins if set
const stored = window.localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') return stored;
// otherwise, respect system preference
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(getInitialTheme);
// Keep <html data-theme> in sync (useful for CSS variables/Tailwind themes)
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
window.localStorage.setItem('theme', theme);
}, [theme]);
const value = useMemo<ThemeContextValue>(() => ({
theme,
setTheme,
toggle: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
}), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
return ctx;
}
Why this is robust:
createContext<T | undefined>(undefined)+ a custom hook yields a clear error when the provider is missing.useMemoensures consumers don’t re‑render unlessthemechanges.- The
getInitialThemefunction avoids hydration issues and respects system settings.
Usage:
// src/components/ThemeSwitcher.tsx
import { useTheme } from '@/contexts/ThemeContext';
export function ThemeSwitcher() {
const { theme, toggle } = useTheme();
return (
<button className="border px-3 py-1 rounded" onClick={toggle}>
Theme: {theme} (toggle)
</button>
);
}
Provider wiring:
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@/contexts/ThemeContext';
import AppRouter from '@/app/AppRouter';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<AppRouter />
</ThemeProvider>
</React.StrictMode>,
);
2) Dependency Injection via Context (API client example)
Provide a service object so components don’t import concrete implementations directly.
// src/contexts/ApiContext.tsx
import { createContext, useContext, type ReactNode } from 'react';
import { http } from '@/lib/api/http'; // your Axios instance from earlier parts
// Service contract (simple for now; expand if you add more services)
export type ApiContextValue = { http: typeof http };
const ApiContext = createContext<ApiContextValue | undefined>(undefined);
export function ApiProvider({ children }: { children: ReactNode }) {
// You can swap this for a mock in tests
const value: ApiContextValue = { http };
return <ApiContext.Provider value={value}>{children}</ApiContext.Provider>;
}
export function useApi() {
const ctx = useContext(ApiContext);
if (!ctx) throw new Error('useApi must be used inside <ApiProvider>');
return ctx;
}
Consume the injected service:
// src/components/ServerPing.tsx
import { useEffect, useState } from 'react';
import { useApi } from '@/contexts/ApiContext';
export function ServerPing() {
const { http } = useApi(); // DI: get the service from context
const [status, setStatus] = useState('checking…');
useEffect(() => {
const ctrl = new AbortController();
http.get('/ping', { signal: ctrl.signal })
.then(() => setStatus('✅ online'))
.catch(() => setStatus('❌ offline'));
return () => ctrl.abort();
}, [http]);
return <p>Server status: {status}</p>;
}
Provider composition (example):
// src/app/AppProviders.tsx
import type { ReactNode } from 'react';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { ApiProvider } from '@/contexts/ApiContext';
export function AppProviders({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<ApiProvider>
{children}
</ApiProvider>
</ThemeProvider>
);
}
}
3) Avoiding common Context pitfalls
- Using
useContextwithout Provider → wrap with a custom hook that throws. - Unnecessary re‑renders → memoize the value object (
useMemo) and avoid inline object literals as values. - Over‑contexting → split by domain (Theme, Api, I18n) instead of one huge context.
- Mutating non‑stable services → keep service instances stable; replace via Provider if you need a different impl.
4) Optional: Split State/Dispatch Contexts (advanced)
Reducing re‑renders further by separating readers (state) from writers (dispatch/setters).
// src/contexts/ThemeSplitContext.tsx
import { createContext, useContext, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react';
import type { Theme } from './ThemeContext';
const ThemeStateCtx = createContext<Theme | undefined>(undefined);
const ThemeSetCtx = createContext<Dispatch<SetStateAction<Theme>> | undefined>(undefined);
export function ThemeSplitProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
return (
<ThemeStateCtx.Provider value={theme}>
<ThemeSetCtx.Provider value={setTheme}>{children}</ThemeSetCtx.Provider>
</ThemeStateCtx.Provider>
);
}
export function useThemeState() {
const v = useContext(ThemeStateCtx);
if (v === undefined) throw new Error('useThemeState must be used within ThemeSplitProvider');
return v;
}
export function useThemeSet() {
const v = useContext(ThemeSetCtx);
if (v === undefined) throw new Error('useThemeSet must be used within ThemeSplitProvider');
return v;
}
Consumers that only read the theme subscribe to ThemeStateCtx and won’t re‑render when only the setter changes.
✅ Wrap‑Up
You now have a fault‑tolerant Context pattern that:
- Guards against missing providers
- Memoizes values to minimize renders
- Persists theme and respects system preference
- Injects services cleanly for testing and environment swaps
Use Context for small, cross‑cutting values and DI; keep server data in your data layer and complex UI state in a dedicated state manager.
Comments
Post a Comment