Skip to content
All essays
WebMarch 12, 202410 min

React Context API Patterns

Advanced patterns and best practices for using React Context API effectively in your applications

Ü
Ümit Uz
Mobile & Full Stack Developer

React Context API provides a way to pass data through the component tree without having to pass props manually at every level. While powerful, it comes with its own set of patterns and anti-patterns. Let's explore how to use Context effectively.

Understanding Context Basics

Context is designed for sharing "global" data that can be considered "global" for a tree of React components, like the current authenticated user, theme, or preferred language. It's often overused for problems that have simpler solutions.

When to Use Context

Use Context when: multiple components at different nesting levels need access to the same data, you want to avoid prop drilling through many layers, and the data changes infrequently. Don't use Context when: a simple prop would suffice, data changes frequently causing many re-renders, or for complex state management needs better served by dedicated libraries.

Creating Context

The createContext function creates a Context object. Both the Context object and its Provider component are exported for use throughout the application.

typescript
const ThemeContext = createContext<{
  theme: 'light' | 'dark';
  toggleTheme: () => void;
} | undefined>(undefined);

Notice we set the default value to undefined. This ensures TypeScript will catch cases where Context is used outside a Provider.

Provider Pattern

Wrap your application or component tree with the Provider to make the context value available to all nested components.

typescript
function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

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

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

Optimizing Provider Re-renders

The Provider component re-renders all consumers whenever the value prop changes. Memoize the value to prevent unnecessary re-renders.

typescript
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const value = useMemo(() => ({
    theme,
    toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light')
  }), [theme]);

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

Consumer Pattern

Consumers access the context value using the useContext Hook.

Custom Hook Pattern

Create a custom hook for consuming context. This provides a clean API and ensures type safety.

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

The error check ensures the hook is used within a Provider, catching bugs early.

Separation of Concerns Pattern

Split context when you have values that change at different rates. This prevents components from re-rendering when unrelated values change.

typescript
// Static or rarely changing values
const ConfigContext = createContext({});

// Frequently changing values
const UserContext = createContext({});

Components consuming ConfigContext won't re-render when UserContext changes, and vice versa.

Compound Components Pattern

Context enables the compound components pattern, where components work together through implicit state sharing.

typescript
const TabsContext = createContext(null);

function Tabs({ children, defaultValue }) {
  const [activeTab, setActiveTab] = useState(defaultValue);

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

function TabList({ children }) {
  return <div role="tablist">{children}</div>;
}

function Tab({ children, value }) {
  const { activeTab, setActiveTab } = useContext(TabsContext);

  return (
    <button
      role="tab"
      aria-selected={activeTab === value}
      onClick={() => setActiveTab(value)}
    >
      {children}
    </button>
  );
}

function TabPanels({ children }) {
  return <div>{children}</div>;
}

function TabPanel({ children, value }) {
  const { activeTab } = useContext(TabsContext);

  if (activeTab !== value) return null;

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

State Reducer Pattern

For complex context logic, use the reducer pattern. This keeps state updates predictable and testable.

typescript
type State = { count: number };
type Action = { type: 'increment' } | { type: 'decrement' };

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

function counterReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      return state;
  }
}

function CounterProvider({ children }) {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

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

Context Composition

Compose multiple contexts to build complex feature sets. Each context handles a specific concern.

typescript
function AppProviders({ children }) {
  return (
    <AuthProvider>
      <ThemeProvider>
        <LanguageProvider>
          {children}
        </LanguageProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

Context Selectors Pattern

To prevent unnecessary re-renders, create context consumers that select only the data they need.

typescript
function useContextSelector<T>(
  context: Context<T>,
  selector: (value: T) => unknown
) {
  const value = useContext(context);
  return selector(value);
}

// Usage
const theme = useContextSelector(ThemeContext, ctx => ctx.theme);

React 18+ provides useSyncExternalStore and useContext for similar optimizations, but custom selector patterns work in earlier versions.

Context with Server Components

With React Server Components, you can create contexts that work across server and client boundaries.

typescript
"use client";

import { createContext, useContext } from 'react';

const ThemeContext = createContext('light');

export function useTheme() {
  return useContext(ThemeContext);
}

export function ThemeProvider({
  children,
  initialTheme
}: {
  children: React.ReactNode;
  initialTheme: string;
}) {
  return (
    <ThemeContext.Provider value={initialTheme}>
      {children}
    </ThemeContext.Provider>
  );
}

Testing Context

Test context-consuming components by wrapping them with the necessary providers.

typescript
function renderWithProviders(
  ui: React.ReactElement,
  { theme = 'light' } = {}
) {
  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <ThemeProvider initialTheme={theme}>
        {children}
      </ThemeProvider>
    );
  }

  return render(ui, { wrapper: Wrapper });
}

test('consumes theme context', () => {
  renderWithProviders(<ThemeDisplay />, { theme: 'dark' });
  expect(screen.getByText(/dark/i)).toBeInTheDocument();
});

Performance Considerations

Context has performance implications you should understand.

Unnecessary Re-renders

All consumers re-render when context value changes. Mitigate this by: splitting contexts by update frequency, memoizing context values, and using selectors to consume only needed data.

Object Reference Stability

Always memoize context values to prevent reference changes that trigger unnecessary re-renders.

typescript
// ❌ Bad: New object every render
<Context.Provider value={{ data, update }} />

// ✅ Good: Stable reference
const value = useMemo(() => ({ data, update }), [data]);
<Context.Provider value={value} />

Common Anti-Patterns

Avoid these common mistakes: putting everything in context causing performance issues, updating context too frequently from many sources, creating deeply nested provider components, and using context for complex state better handled by dedicated libraries.

Context vs External Libraries

Context is great for global UI state, but specialized libraries often serve better for specific needs: Redux Toolkit for complex global state, React Query for server state, Zustand or Jotai for simple global state, and React Hook Form for form state.

Best Practices

  1. 1Create contexts for specific concerns, not one giant context
  2. 2Use TypeScript for type safety
  3. 3Provide custom hooks for consuming context
  4. 4Memoize context values
  5. 5Split contexts by update frequency
  6. 6Document context usage and expected values
  7. 7Use error boundaries to catch context errors gracefully

Conclusion

React Context API is a powerful tool for sharing state across your component tree. Understanding when to use it, how to structure it, and how to optimize it will help you build more maintainable applications.

Use context sparingly and for appropriate use cases. Split contexts by concern, optimize providers with memoization, and create custom hooks for clean APIs. When used correctly, context eliminates prop drilling and provides a clean way to share global state.

Remember: just because you can use context for everything doesn't mean you should. Choose the right tool for the job, and use context where it shines: sharing truly global state that many components need.

Related essays

Next essay
CSS Architecture for Large Scale Apps