MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

React Context API: State Management Without Redux

Use React Context API for global state: patterns, performance, and when to replace Redux.

ReactContext APIState Management

By MinhVo

Introduction

The React Context API provides a way to share state across components without passing props through every level of the component tree. Before Context, developers relied on prop drilling (passing props through intermediate components) or external state management libraries like Redux for global state. Context offers a built-in, lightweight alternative that handles many common state management scenarios.

Context is designed for "global" data that many components need: the current user, theme, locale, or authentication state. It's not a replacement for all state management—local component state and specialized libraries still have their place. Understanding when and how to use Context effectively is key to writing clean, maintainable React applications.

This guide covers the Context API from basics to advanced patterns, including performance optimization, testing strategies, and when to reach for alternatives like Redux or Zustand.

State management architecture

Understanding Context API: Core Concepts

The Problem Context Solves

Without Context, sharing data across distant components requires prop drilling:

// Without Context: prop drilling through 4 levels
function App() {
  const [user, setUser] = useState(null);
  return <Layout user={user} setUser={setUser} />;
}
 
function Layout({ user, setUser }) {
  return (
    <div>
      <Header user={user} setUser={setUser} />
      <Main user={user} />
    </div>
  );
}
 
function Header({ user, setUser }) {
  return (
    <header>
      <Navigation user={user} />
      <UserMenu user={user} setUser={setUser} />
    </header>
  );
}
 
function Navigation({ user }) {
  return <nav>{user ? <DashboardLink /> : <LoginLink />}</nav>;
}

Every intermediate component receives user and setUser even though it doesn't use them directly. This makes refactoring difficult and components tightly coupled.

Context Solution

// With Context: direct access where needed
const UserContext = createContext(null);
const UserDispatchContext = createContext(null);
 
function App() {
  const [user, setUser] = useState(null);
  return (
    <UserContext.Provider value={user}>
      <UserDispatchContext.Provider value={setUser}>
        <Layout />
      </UserDispatchContext.Provider>
    </UserContext.Provider>
  );
}
 
function Layout() {
  return (
    <div>
      <Header />
      <Main />
    </div>
  );
}
 
function Header() {
  return (
    <header>
      <Navigation />
      <UserMenu />
    </header>
  );
}
 
function Navigation() {
  const user = useContext(UserContext);
  return <nav>{user ? <DashboardLink /> : <LoginLink />}</nav>;
}

Now Navigation accesses user directly without any intermediate component knowing about it.

How Context Works Internally

Context uses a provider-consumer pattern:

  1. Create context: createContext(defaultValue) creates a context object
  2. Provide context: <Context.Provider value={...}> makes the value available to descendants
  3. Consume context: useContext(Context) reads the current value from the nearest provider

When the provider's value changes, ALL consumers re-render—even if they only use a subset of the value. This is the key performance consideration with Context.

Separating Read and Write Contexts

A critical pattern for performance is separating the data context from the dispatch context:

const UserContext = createContext<User | null>(null);
const UserDispatchContext = createContext<Dispatch<SetStateAction<User | null>>>(() => {});
 
function UserProvider({ children }) {
  const [user, setUser] = useState<User | null>(null);
  return (
    <UserContext.Provider value={user}>
      <UserDispatchContext.Provider value={setUser}>
        {children}
      </UserDispatchContext.Provider>
    </UserContext.Provider>
  );
}
 
// Component that only reads user - re-renders when user changes
function UserProfile() {
  const user = useContext(UserContext);
  return <div>{user?.name}</div>;
}
 
// Component that only sets user - NEVER re-renders when user changes
function LogoutButton() {
  const setUser = useContext(UserDispatchContext);
  return <button onClick={() => setUser(null)}>Logout</button>;
}

Context provider tree

Architecture and Design Patterns

Pattern 1: Compound Context Provider

Combine related contexts into a single provider:

interface AppContextType {
  user: User | null;
  theme: Theme;
  locale: Locale;
  notifications: Notification[];
}
 
const AppContext = createContext<AppContextType | null>(null);
 
function AppProvider({ children }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<Theme>('light');
  const [locale, setLocale] = useState<Locale>('en');
  const [notifications, setNotifications] = useState<Notification[]>([]);
 
  const value = useMemo(() => ({
    user, theme, locale, notifications,
    setUser, setTheme, setLocale, setNotifications,
  }), [user, theme, locale, notifications]);
 
  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

Pattern 2: Context with Reducer

For complex state logic, combine Context with useReducer:

type Action =
  | { type: 'LOGIN'; payload: User }
  | { type: 'LOGOUT' }
  | { type: 'UPDATE_PROFILE'; payload: Partial<User> };
 
interface State {
  user: User | null;
  loading: boolean;
  error: string | null;
}
 
const initialState: State = { user: null, loading: false, error: null };
 
function authReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'LOGIN':
      return { ...state, user: action.payload, error: null };
    case 'LOGOUT':
      return { ...state, user: null };
    case 'UPDATE_PROFILE':
      return { ...state, user: state.user ? { ...state.user, ...action.payload } : null };
    default:
      return state;
  }
}
 
const AuthStateContext = createContext<State>(initialState);
const AuthDispatchContext = createContext<Dispatch<Action>>(() => {});
 
function AuthProvider({ children }) {
  const [state, dispatch] = useReducer(authReducer, initialState);
  return (
    <AuthStateContext.Provider value={state}>
      <AuthDispatchContext.Provider value={dispatch}>
        {children}
      </AuthDispatchContext.Provider>
    </AuthStateContext.Provider>
  );
}
 
function useAuthState() {
  const context = useContext(AuthStateContext);
  if (context === undefined) throw new Error('useAuthState must be used within AuthProvider');
  return context;
}
 
function useAuthDispatch() {
  const context = useContext(AuthDispatchContext);
  if (context === undefined) throw new Error('useAuthDispatch must be used within AuthProvider');
  return context;
}

Pattern 3: Context Selector Pattern

Prevent unnecessary re-renders by selecting only needed values:

function useContextSelector<T, S>(context: Context<T>, selector: (value: T) => S): S {
  const value = useContext(context);
  return selector(value);
}
 
// Usage
function UserAvatar() {
  const avatar = useContextSelector(UserContext, (user) => user?.avatar);
  return <img src={avatar} alt="User avatar" />;
}

For production use, consider use-context-selector library which provides proper memoization.

Step-by-Step Implementation

Step 1: Define Your Contexts

// contexts/ThemeContext.tsx
type Theme = 'light' | 'dark';
 
interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}
 
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>(() => {
    const saved = localStorage.getItem('theme');
    return (saved as Theme) || 'light';
  });
 
  useEffect(() => {
    localStorage.setItem('theme', theme);
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);
 
  const toggleTheme = useCallback(() => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  }, []);
 
  const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
 
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
 
export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

Step 2: Create a Provider Wrapper

// contexts/AppProviders.tsx
export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider>
      <AuthProvider>
        <NotificationProvider>
          {children}
        </NotificationProvider>
      </AuthProvider>
    </ThemeProvider>
  );
}
 
// In your app entry point
function App() {
  return (
    <AppProviders>
      <Router>
        <Routes />
      </Router>
    </AppProviders>
  );
}

Step 3: Consume Context in Components

function Header() {
  const { theme, toggleTheme } = useTheme();
  const { user, logout } = useAuth();
 
  return (
    <header>
      <Logo />
      <Navigation />
      {user ? (
        <UserMenu user={user} onLogout={logout} />
      ) : (
        <LoginButton />
      )}
      <ThemeToggle current={theme} onToggle={toggleTheme} />
    </header>
  );
}

Step 4: Handle Context in Tests

import { render, screen } from '@testing-library/react';
 
function renderWithProviders(ui: React.ReactElement, options = {}) {
  const { theme = 'light', user = null, ...renderOptions } = options;
 
  function Wrapper({ children }) {
    return (
      <ThemeProvider initialTheme={theme}>
        <AuthProvider initialUser={user}>
          {children}
        </AuthProvider>
      </ThemeProvider>
    );
  }
 
  return render(ui, { wrapper: Wrapper, ...renderOptions });
}
 
// Usage in tests
test('shows user name when logged in', () => {
  renderWithProviders(<Header />, {
    user: { name: 'John', avatar: '/avatar.jpg' },
  });
  expect(screen.getByText('John')).toBeInTheDocument();
});
 
test('shows login button when not logged in', () => {
  renderWithProviders(<Header />, { user: null });
  expect(screen.getByText('Login')).toBeInTheDocument();
});

Step 5: Performance Optimization

// Memoize context value to prevent unnecessary re-renders
function OptimizedProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);
 
  // Memoize the context value
  const contextValue = useMemo(() => ({
    ...state,
    dispatch,
  }), [state]);
 
  return (
    <AppContext.Provider value={contextValue}>
      {children}
    </AppContext.Provider>
  );
}
 
// Use React.memo for context consumers
const ExpensiveChild = React.memo(function ExpensiveChild() {
  const { theme } = useTheme();
  return <div className={theme}>Expensive render</div>;
});

Performance comparison

Real-World Use Cases

Use Case 1: Authentication Context

interface AuthContextType {
  user: User | null;
  loading: boolean;
  login: (credentials: LoginCredentials) => Promise<void>;
  logout: () => Promise<void>;
  updateProfile: (data: Partial<User>) => Promise<void>;
}
 
function AuthProvider({ children }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    const token = localStorage.getItem('token');
    if (token) {
      validateToken(token)
        .then(setUser)
        .catch(() => localStorage.removeItem('token'))
        .finally(() => setLoading(false));
    } else {
      setLoading(false);
    }
  }, []);
 
  const login = async (credentials: LoginCredentials) => {
    const { user, token } = await api.login(credentials);
    localStorage.setItem('token', token);
    setUser(user);
  };
 
  const logout = async () => {
    await api.logout();
    localStorage.removeItem('token');
    setUser(null);
  };
 
  const updateProfile = async (data: Partial<User>) => {
    const updated = await api.updateProfile(data);
    setUser(updated);
  };
 
  const value = useMemo(() => ({
    user, loading, login, logout, updateProfile,
  }), [user, loading]);
 
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

Use Case 2: Multi-Language Support

interface I18nContextType {
  locale: string;
  t: (key: string, params?: Record<string, string>) => string;
  setLocale: (locale: string) => void;
}
 
function I18nProvider({ children, messages }) {
  const [locale, setLocale] = useState('en');
 
  const t = useCallback((key: string, params?: Record<string, string>) => {
    let translation = messages[locale]?.[key] || key;
    if (params) {
      Object.entries(params).forEach(([k, v]) => {
        translation = translation.replace(`{${k}}`, v);
      });
    }
    return translation;
  }, [locale, messages]);
 
  const value = useMemo(() => ({ locale, t, setLocale }), [locale, t]);
 
  return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
 
// Usage
function WelcomeMessage() {
  const { t } = useI18n();
  return <h1>{t('welcome', { name: 'User' })}</h1>;
}

Use Case 3: Feature Flags

interface FeatureFlags {
  enableNewDashboard: boolean;
  enableBetaFeatures: boolean;
  enableAnalytics: boolean;
}
 
const FeatureFlagsContext = createContext<FeatureFlags>(defaultFlags);
 
function FeatureFlagsProvider({ children, userId }) {
  const [flags, setFlags] = useState<FeatureFlags>(defaultFlags);
 
  useEffect(() => {
    fetchFeatureFlags(userId).then(setFlags);
  }, [userId]);
 
  return (
    <FeatureFlagsContext.Provider value={flags}>
      {children}
    </FeatureFlagsContext.Provider>
  );
}
 
function useFeatureFlag(flag: keyof FeatureFlags): boolean {
  const flags = useContext(FeatureFlagsContext);
  return flags[flag];
}
 
// Usage
function Dashboard() {
  const showNewDashboard = useFeatureFlag('enableNewDashboard');
  return showNewDashboard ? <NewDashboard /> : <LegacyDashboard />;
}

Best Practices for Production

  1. Separate read and write contexts: Use separate contexts for data and update functions to prevent unnecessary re-renders.

  2. Memoize context values: Always wrap the value in useMemo to maintain referential stability.

  3. Create custom hooks: Export useXxx() hooks for each context. Throw errors if used outside providers.

  4. Keep contexts focused: One context per concern (auth, theme, locale). Don't put everything in one global context.

  5. Handle loading states: Include loading indicators in your context for async operations.

  6. Persist important state: Save theme, locale, and auth tokens to localStorage.

  7. Test with providers: Create test utilities that wrap components in the necessary providers.

  8. Consider performance: For high-frequency updates, consider external stores like Zustand or Jotai.

Common Pitfalls and Solutions

PitfallImpactSolution
Single global contextAll consumers re-render on any changeSplit into focused contexts
Not memoizing valueProvider re-renders cause all consumers to re-renderUse useMemo for context value
Context in frequently updating componentsPerformance degradationUse external store or selector pattern
Missing error handlingSilent failures when context is undefinedThrow descriptive errors in custom hooks
Prop drilling through providersDefeats the purpose of contextRestructure component tree
Not testing contextBugs in provider logicCreate test utilities with providers

Performance Optimization

When Context Causes Performance Issues

Context re-renders ALL consumers when the provided value changes. This can be problematic for:

  • High-frequency updates (mouse position, scroll position)
  • Large context values where only a subset is needed
  • Deep component trees with many consumers

Solutions

// Solution 1: External store for high-frequency updates
import { useSyncExternalStore } from 'react';
 
const mousePositionStore = {
  position: { x: 0, y: 0 },
  listeners: new Set<() => void>(),
 
  subscribe(listener: () => void) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  },
 
  getSnapshot() {
    return this.position;
  },
 
  update(x: number, y: number) {
    this.position = { x, y };
    this.listeners.forEach(l => l());
  },
};
 
window.addEventListener('mousemove', (e) => {
  mousePositionStore.update(e.clientX, e.clientY);
});
 
function useMousePosition() {
  return useSyncExternalStore(
    mousePositionStore.subscribe.bind(mousePositionStore),
    mousePositionStore.getSnapshot.bind(mousePositionStore)
  );
}
 
// Solution 2: Memoized selector pattern
function useUser() {
  const { user } = useContext(UserContext);
  return useMemo(() => user, [user]);
}
 
function useUserName() {
  const user = useUser();
  return useMemo(() => user?.name, [user]);
}

Comparison with Alternatives

FeatureContext APIReduxZustandJotai
Built-inYesNoNoNo
Bundle Size0KB~11KB~1KB~2KB
BoilerplateLowHighLowVery Low
DevToolsNoYesYesYes
MiddlewareNoYesYesYes
Async SupportManualThunk/SagaBuilt-inBuilt-in
PerformanceRe-renders allSelectiveSelectiveSelective
Learning CurveLowHighLowLow

Advanced Patterns

Context with Middleware

function createMiddlewareContext<T>(reducer, initialState, middlewares = []) {
  const StateContext = createContext<T>(initialState);
  const DispatchContext = createContext<Dispatch<any>>(() => {});
 
  function Provider({ children }) {
    const [state, baseDispatch] = useReducer(reducer, initialState);
 
    const dispatch = useCallback((action) => {
      // Apply middlewares
      let processedAction = action;
      for (const middleware of middlewares) {
        processedAction = middleware(processedAction, state);
      }
      baseDispatch(processedAction);
    }, [state]);
 
    return (
      <StateContext.Provider value={state}>
        <DispatchContext.Provider value={dispatch}>
          {children}
        </DispatchContext.Provider>
      </StateContext.Provider>
    );
  }
 
  return { Provider, useState: () => useContext(StateContext), useDispatch: () => useContext(DispatchContext) };
}
 
// Logger middleware
const loggerMiddleware = (action, state) => {
  console.log('Action:', action);
  console.log('Previous State:', state);
  return action;
};

Context Composition

function composeProviders(...providers) {
  return function ComposedProviders({ children }) {
    return providers.reduceRight(
      (acc, Provider) => <Provider>{acc}</Provider>,
      children
    );
  };
}
 
const AppProvider = composeProviders(
  ThemeProvider,
  AuthProvider,
  NotificationProvider,
  I18nProvider
);
 
// Usage
function App() {
  return (
    <AppProvider>
      <AppContent />
    </AppProvider>
  );
}

Testing Strategies

// Test helper for context
function createTestProvider(context, value) {
  return function TestProvider({ children }) {
    return <context.Provider value={value}>{children}</context.Provider>;
  };
}
 
// Test custom hook
import { renderHook, act } from '@testing-library/react';
 
test('useTheme returns theme from context', () => {
  const wrapper = ({ children }) => (
    <ThemeProvider initialTheme="dark">{children}</ThemeProvider>
  );
 
  const { result } = renderHook(() => useTheme(), { wrapper });
  expect(result.current.theme).toBe('dark');
});
 
test('toggleTheme switches theme', () => {
  const wrapper = ({ children }) => (
    <ThemeProvider initialTheme="light">{children}</ThemeProvider>
  );
 
  const { result } = renderHook(() => useTheme(), { wrapper });
 
  act(() => {
    result.current.toggleTheme();
  });
 
  expect(result.current.theme).toBe('dark');
});

Context Composition Patterns for Complex Applications

Large applications often need multiple contexts for different concerns: authentication, theming, feature flags, internationalization, and more. Wrapping the application in a deeply nested provider tree creates the "provider hell" problem that makes the component tree difficult to read and maintain. The composition pattern solves this by creating a single AppProviders component that composes all providers in the correct order.

function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider>
      <AuthProvider>
        <FeatureFlagProvider>
          <I18nProvider>
            <NotificationProvider>
              {children}
            </NotificationProvider>
          </I18nProvider>
        </FeatureFlagProvider>
      </AuthProvider>
    </ThemeProvider>
  );
}

The order of providers matters because inner providers may depend on values from outer providers. For example, the AuthProvider might need the current theme from ThemeProvider to style the login form, and the I18nProvider might need the user's locale from AuthProvider. Document the dependency order and use TypeScript to enforce that providers are composed in the correct sequence.

Testing Components That Consume Context

Testing components that rely on context requires providing mock context values during rendering. The renderHook function from React Testing Library accepts a wrapper option that wraps the hook in a provider component. Create factory functions that return pre-configured wrapper components with realistic test values for each context.

function createAuthWrapper(user: User | null) {
  return function AuthWrapper({ children }: { children: React.ReactNode }) {
    return (
      <AuthContext.Provider value={{ user, login: jest.fn(), logout: jest.fn() }}>
        {children}
      </AuthContext.Provider>
    );
  };
}
 
test('shows dashboard for authenticated users', () => {
  const wrapper = createAuthWrapper({ id: '1', name: 'Alice', role: 'admin' });
  render(<Dashboard />, { wrapper });
  expect(screen.getByText('Welcome, Alice')).toBeInTheDocument();
});

Avoid importing the actual provider component in tests because it may have side effects like API calls or event listeners. Instead, create minimal mock providers that supply only the values the component under test actually consumes. This approach isolates the test from changes in the provider implementation and makes tests faster and more predictable.

Future Outlook

  • React Server Components integration: Context behavior in server/client boundaries
  • Compiler optimization: React Compiler may optimize context re-renders automatically
  • Improved selector support: Built-in support for context selectors without third-party libraries

Conclusion

The React Context API is a powerful built-in solution for sharing state across components. It handles many scenarios that previously required external libraries, but it's not a one-size-fits-all solution.

Key takeaways:

  1. Use Context for truly global data: user, theme, locale, feature flags
  2. Separate read and write contexts for better performance
  3. Memoize context values to prevent unnecessary re-renders
  4. Create custom hooks for clean, type-safe context consumption
  5. Keep contexts focused—one concern per context
  6. Consider external stores for high-frequency updates or complex state
  7. Test with providers to ensure context logic works correctly

Context API strikes the right balance between simplicity and capability for most applications. Start with Context, and reach for external libraries only when you hit its limitations.