References

Context Patterns & Component Composition

Context API, compound components, render props, and state sharing strategies

Core Patterns

  • When to Read This
  • Context API
  • Context Performance Optimization
  • Compound Components

When to Read This

  • Sharing state across components
  • Avoiding prop drilling
  • Building component APIs
  • Creating flexible component compositions
  • Performance issues with context

Context API

✅ Basic Context Setup

import { createContext, useContext, useState, ReactNode } from 'react';

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

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

interface ThemeProviderProps {
  children: ReactNode;
}

export function ThemeProvider({ children }: ThemeProviderProps) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

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

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

function App() {
  return (
    <ThemeProvider>
      <Header />
      <Content />
    </ThemeProvider>
  );
}

function Header() {
  const { theme, toggleTheme } = useTheme();
  return <button onClick={toggleTheme}>Current: {theme}</button>;
}

✅ Context with Reducer

import { createContext, useContext, useReducer, ReactNode } from 'react';

interface State {
  user: User | null;
  loading: boolean;
  error: string | null;
}

type Action =
  | { type: 'LOGIN_START' }
  | { type: 'LOGIN_SUCCESS'; payload: User }
  | { type: 'LOGIN_ERROR'; payload: string }
  | { type: 'LOGOUT' };

const initialState: State = {
  user: null,
  loading: false,
  error: null,
};

function authReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'LOGIN_START':
      return { ...state, loading: true, error: null };
    case 'LOGIN_SUCCESS':
      return { user: action.payload, loading: false, error: null };
    case 'LOGIN_ERROR':
      return { ...state, loading: false, error: action.payload };
    case 'LOGOUT':
      return { ...initialState };
    default:
      return state;
  }
}

const AuthContext = createContext<{
  state: State;
  dispatch: React.Dispatch<Action>;
} | undefined>(undefined);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, initialState);

  return (
    <AuthContext.Provider value={{ state, dispatch }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error('useAuth must be used within AuthProvider');
  return context;
}

function LoginForm() {
  const { state, dispatch } = useAuth();

  const handleLogin = async (credentials: Credentials) => {
    dispatch({ type: 'LOGIN_START' });
    try {
      const user = await api.login(credentials);
      dispatch({ type: 'LOGIN_SUCCESS', payload: user });
    } catch (error) {
      dispatch({ type: 'LOGIN_ERROR', payload: error.message });
    }
  };

  return <form onSubmit={handleLogin}>...</form>;
}

Context Performance Optimization

⚠️ Problem: Unnecessary Re-renders

// ❌ PROBLEM: All consumers re-render when any value changes
function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [settings, setSettings] = useState({});

  const value = { user, setUser, theme, setTheme, settings, setSettings };

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

// Component using only theme re-renders when user changes!
function ThemeButton() {
  const { theme, setTheme } = useApp();
  return <button onClick={() => setTheme('dark')}>{theme}</button>;
}

✅ Solution 1: Split Contexts

// Separate contexts for independent data
function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  return <UserContext.Provider value={{ user, setUser }}>{children}</UserContext.Provider>;
}

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
}

function App() {
  return (
    <UserProvider>
      <ThemeProvider>
        <AppContent />
      </ThemeProvider>
    </UserProvider>
  );
}

// ThemeButton only re-renders when theme changes
function ThemeButton() {
  const { theme, setTheme } = useTheme();
  return <button onClick={() => setTheme('dark')}>{theme}</button>;
}

✅ Solution 2: Memoize Context Value

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  // ❌ WRONG: New object on every render
  const value = { user, setUser, theme, setTheme };

  // ✅ CORRECT: Memoized value
  const value = useMemo(
    () => ({ user, setUser, theme, setTheme }),
    [user, theme]
  );

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

✅ Solution 3: Selector Pattern

const StateContext = createContext<State | undefined>(undefined);
const DispatchContext = createContext<Dispatch | undefined>(undefined);

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
}

// Only re-renders when selected value changes
function useSelector<T>(selector: (state: State) => T): T {
  const state = useContext(StateContext);
  if (!state) throw new Error('Must be used within provider');
  return selector(state);
}

// Only re-renders when theme changes
function ThemeButton() {
  const theme = useSelector(state => state.theme);
  const dispatch = useContext(DispatchContext);

  return <button onClick={() => dispatch({ type: 'TOGGLE_THEME' })}>{theme}</button>;
}

Compound Components

✅ Pattern: Implicit State Sharing

interface TabsContextType {
  activeTab: string;
  setActiveTab: (id: string) => void;
}

const TabsContext = createContext<TabsContextType | undefined>(undefined);

export function Tabs({ children, defaultTab }: { children: ReactNode; defaultTab: string }) {
  const [activeTab, setActiveTab] = useState(defaultTab);

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

export function TabList({ children }: { children: ReactNode }) {
  return <div className="tab-list" role="tablist">{children}</div>;
}

export function Tab({ id, children }: { id: string; children: ReactNode }) {
  const { activeTab, setActiveTab } = useContext(TabsContext)!;
  const isActive = activeTab === id;

  return (
    <button
      role="tab"
      aria-selected={isActive}
      onClick={() => setActiveTab(id)}
      className={isActive ? 'active' : ''}
    >
      {children}
    </button>
  );
}

export function TabPanel({ id, children }: { id: string; children: ReactNode }) {
  const { activeTab } = useContext(TabsContext)!;
  if (activeTab !== id) return null;

  return <div role="tabpanel">{children}</div>;
}

// Clean API without prop drilling
function App() {
  return (
    <Tabs defaultTab="profile">
      <TabList>
        <Tab id="profile">Profile</Tab>
        <Tab id="settings">Settings</Tab>
        <Tab id="billing">Billing</Tab>
      </TabList>

      <TabPanel id="profile">
        <ProfileContent />
      </TabPanel>
      <TabPanel id="settings">
        <SettingsContent />
      </TabPanel>
      <TabPanel id="billing">
        <BillingContent />
      </TabPanel>
    </Tabs>
  );
}

✅ Pattern: Flexible Composition

interface DropdownContextType {
  isOpen: boolean;
  toggle: () => void;
  close: () => void;
}

const DropdownContext = createContext<DropdownContextType | undefined>(undefined);

export function Dropdown({ children }: { children: ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);

  const toggle = () => setIsOpen(prev => !prev);
  const close = () => setIsOpen(false);

  return (
    <DropdownContext.Provider value={{ isOpen, toggle, close }}>
      <div className="dropdown">{children}</div>
    </DropdownContext.Provider>
  );
}

export function DropdownTrigger({ children }: { children: ReactNode }) {
  const { toggle } = useContext(DropdownContext)!;
  return <button onClick={toggle}>{children}</button>;
}

export function DropdownMenu({ children }: { children: ReactNode }) {
  const { isOpen, close } = useContext(DropdownContext)!;

  useEffect(() => {
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape') close();
    };
    document.addEventListener('keydown', handleEscape);
    return () => document.removeEventListener('keydown', handleEscape);
  }, [close]);

  if (!isOpen) return null;

  return <div className="dropdown-menu">{children}</div>;
}

export function DropdownItem({ onClick, children }: { onClick: () => void; children: ReactNode }) {
  const { close } = useContext(DropdownContext)!;

  const handleClick = () => {
    onClick();
    close();
  };

  return <button className="dropdown-item" onClick={handleClick}>{children}</button>;
}

function UserMenu() {
  return (
    <Dropdown>
      <DropdownTrigger>
        <Avatar />
      </DropdownTrigger>
      <DropdownMenu>
        <DropdownItem onClick={() => navigate('/profile')}>Profile</DropdownItem>
        <DropdownItem onClick={() => navigate('/settings')}>Settings</DropdownItem>
        <DropdownItem onClick={logout}>Logout</DropdownItem>
      </DropdownMenu>
    </Dropdown>
  );
}

Render Props Pattern

✅ Flexibility with Render Props

interface MousePosition {
  x: number;
  y: number;
}

function MouseTracker({ render }: { render: (pos: MousePosition) => ReactNode }) {
  const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 });

  useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };

    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);

  return <>{render(position)}</>;
}

function App() {
  return (
    <MouseTracker
      render={({ x, y }) => (
        <div>
          Mouse position: {x}, {y}
        </div>
      )}
    />
  );
}

✅ Children as Function

function DataFetcher<T>({ url, children }: { url: string; children: (data: T | null, loading: boolean, error: Error | null) => ReactNode }) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [url]);

  return <>{children(data, loading, error)}</>;
}

function UserProfile() {
  return (
    <DataFetcher<User> url="/api/user">
      {(user, loading, error) => {
        if (loading) return <Spinner />;
        if (error) return <Error message={error.message} />;
        if (!user) return null;
        return <div>{user.name}</div>;
      }}
    </DataFetcher>
  );
}

Hooks vs Render Props vs HOC

Decision Matrix

PatternUse CaseProsCons
HooksMost casesClean, composable, no nestingRequires React 16.8+
Render PropsFlexible rendering, multiple childrenType-safe, explicitNesting, verbose
HOCLegacy, decoratorsReusable, middlewareProps collision, hard to type

✅ Modern Approach: Hooks

function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };
    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);

  return position;
}

function App() {
  const { x, y } = useMousePosition();
  return <div>Mouse: {x}, {y}</div>;
}

Provider Composition

✅ Multiple Providers

// ❌ PROBLEM: Provider hell
function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <NotificationProvider>
          <RouterProvider>
            <QueryProvider>
              <AppContent />
            </QueryProvider>
          </RouterProvider>
        </NotificationProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

// ✅ SOLUTION: Compose providers
function composeProviders(...providers: React.FC<{ children: ReactNode }>[]) {
  return providers.reduce(
    (Composed, Provider) =>
      ({ children }) => (
        <Composed>
          <Provider>{children}</Provider>
        </Composed>
      ),
    ({ children }) => <>{children}</>
  );
}

const AppProviders = composeProviders(
  AuthProvider,
  ThemeProvider,
  NotificationProvider,
  RouterProvider,
  QueryProvider
);

function App() {
  return (
    <AppProviders>
      <AppContent />
    </AppProviders>
  );
}

Error Boundaries

Error boundaries catch JavaScript errors in their child component tree.

Class-Based Error Boundary

// ErrorBoundary.tsx — class component (hooks don't support error boundaries yet)
interface Props { children: ReactNode; fallback?: ReactNode; }
interface State { hasError: boolean; error: Error | null; }

class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error('Error boundary caught:', error, info.componentStack);
    // Report to error tracking service
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? <DefaultErrorUI error={this.state.error} />;
    }
    return this.props.children;
  }
}

Usage Patterns

// ✅ CORRECT: Granular error boundaries
function App() {
  return (
    <ErrorBoundary fallback={<AppCrashScreen />}>
      <Header />
      <ErrorBoundary fallback={<WidgetError />}>
        <Dashboard />
      </ErrorBoundary>
      <ErrorBoundary fallback={<SidebarError />}>
        <Sidebar />
      </ErrorBoundary>
    </ErrorBoundary>
  );
}

// ✅ CORRECT: Error boundary with reset capability
function ResettableErrorBoundary({ children }: { children: ReactNode }) {
  const [key, setKey] = useState(0);
  return (
    <ErrorBoundary key={key} fallback={
      <div>
        <p>Something went wrong.</p>
        <button onClick={() => setKey(k => k + 1)}>Try Again</button>
      </div>
    }>
      {children}
    </ErrorBoundary>
  );
}

// ❌ WRONG: Single error boundary wrapping entire app
<ErrorBoundary>
  <App /> {/* One error crashes everything */}
</ErrorBoundary>

Note: Error boundaries do NOT catch: event handlers (use try/catch), async code (use .catch()), SSR errors, or errors in the boundary itself.


References