Introduction to useContext

What is useContext?

useContext is one of React's built-in hooks that allows you to consume values from React's Context API. Think of context as a way to share data that can be considered "global" for a tree of React components, without having to pass props manually through every level of the component tree.

Why Do We Need useContext?

Without useContext, sharing data across components that aren't directly connected in the component tree would require:

  • Passing props down through multiple levels of components (prop drilling)
  • Complex state lifting patterns
  • External state management solutions for every case

But real applications often need:

  • Theme data available throughout the application
  • User authentication state across multiple components
  • Localization/language preferences
  • Feature flags or application configuration
  • Shared business logic between distant components

useContext Made Simple

Imagine a family sharing important information:

  1. A parent creates a family memo (create context)
  2. The parent places the memo in a special place all family members can access (provide context)
  3. Any family member can read the memo whenever they need the information (consume context)
  4. When the parent updates the memo, all family members see the latest information (context updates)

This is exactly how useContext works in React!

Basic Syntax

import { createContext, useContext, useState } from "react";

// 1. Create the context
const ThemeContext = createContext(null);

// 2. Create a provider component
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");

  const toggleTheme = () => {
    setTheme(theme === "light" ? "dark" : "light");
  };

  // The value that will be provided to consumers
  const value = {
    theme,
    toggleTheme,
  };

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

// 3. Consumer component using useContext
function ThemedButton() {
  // Consume the context value
  const { theme, toggleTheme } = useContext(ThemeContext);

  return (
    <button
      style={{
        background: theme === "light" ? "#fff" : "#333",
        color: theme === "light" ? "#333" : "#fff",
        border: "1px solid #ccc",
        padding: "8px 16px",
      }}
      onClick={toggleTheme}
    >
      Toggle Theme
    </button>
  );
}

// 4. Use the provider at a high level in your app
function App() {
  return (
    <ThemeProvider>
      <div className="app">
        <header>
          <h1>My App</h1>
          <ThemedButton />
        </header>
        <main>{/* Other components that can also consume ThemeContext */}</main>
      </div>
    </ThemeProvider>
  );
}

Let's break this down:

  1. We create a context with createContext() and provide a default value (used if no matching Provider is found)
  2. We define a Provider component that wraps its children with the context's Provider
  3. The Provider component includes a value prop that holds the data to be shared
  4. Consumer components call useContext() with the context object to access the shared data
  5. Whenever the context value changes, all consuming components re-render

Creating Type-Safe Context

In TypeScript, you can create type-safe context like this:

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

// Define the shape of your context
interface ThemeContextType {
  theme: "light" | "dark";
  toggleTheme: () => void;
}

// Create context with a default value
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

// Provider props
interface ThemeProviderProps {
  children: ReactNode;
}

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

  const toggleTheme = () => {
    setTheme(theme === "light" ? "dark" : "light");
  };

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

// Custom hook for consuming the context
export function useTheme(): ThemeContextType {
  const context = useContext(ThemeContext);

  if (context === undefined) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }

  return context;
}

With this approach:

  • The context and provider are strongly typed
  • The custom hook ensures the context is used correctly
  • TypeScript will enforce correct usage throughout your application

useContext in Real Life

Here's a practical example of using context for a multi-level application:

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

// Define the user type
interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user";
}

// Define the auth context shape
interface AuthContextType {
  user: User | null;
  isAuthenticated: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

// Create the context
const AuthContext = createContext<AuthContextType | undefined>(undefined);

// Provider component
export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  // Derived state
  const isAuthenticated = user !== null;

  // Login function
  const login = async (email: string, password: string) => {
    try {
      // In a real app, this would be an API call
      const response = await mockLoginApi(email, password);
      setUser(response.user);
    } catch (error) {
      console.error("Login failed:", error);
      throw error;
    }
  };

  // Logout function
  const logout = () => {
    setUser(null);
  };

  // Value to provide
  const value = {
    user,
    isAuthenticated,
    login,
    logout,
  };

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

// Custom hook for using the auth context
export function useAuth() {
  const context = useContext(AuthContext);

  if (context === undefined) {
    throw new Error("useAuth must be used within an AuthProvider");
  }

  return context;
}

// Mock API for demo purposes
function mockLoginApi(email: string, password: string) {
  return new Promise<{ user: User }>((resolve, reject) => {
    setTimeout(() => {
      if (email === "user@example.com" && password === "password") {
        resolve({
          user: {
            id: 1,
            name: "John Doe",
            email: "user@example.com",
            role: "user",
          },
        });
      } else if (email === "admin@example.com" && password === "admin") {
        resolve({
          user: {
            id: 2,
            name: "Admin User",
            email: "admin@example.com",
            role: "admin",
          },
        });
      } else {
        reject(new Error("Invalid credentials"));
      }
    }, 1000); // Simulate network delay
  });
}

// Example of a component using the auth context
function ProfilePage() {
  const { user, logout } = useAuth();

  if (!user) {
    return <div>Please log in to view your profile</div>;
  }

  return (
    <div className="profile-page">
      <h1>Welcome, {user.name}</h1>
      <p>Email: {user.email}</p>
      <p>Role: {user.role}</p>
      <button onClick={logout}>Log Out</button>

      {user.role === "admin" && (
        <div className="admin-panel">
          <h2>Admin Panel</h2>
          <p>Only admins can see this section</p>
        </div>
      )}
    </div>
  );
}

// Using the provider in your app
function App() {
  return (
    <AuthProvider>
      <div className="app">
        <header>
          <NavBar />
        </header>
        <main>
          <Routes>
            <Route path="/profile" element={<ProfilePage />} />
            {/* Other routes */}
          </Routes>
        </main>
      </div>
    </AuthProvider>
  );
}

// Navigation component that shows different options based on auth state
function NavBar() {
  const { isAuthenticated, user } = useAuth();

  return (
    <nav>
      <ul>
        <li>
          <Link to="/">Home</Link>
        </li>
        {isAuthenticated ? (
          <>
            <li>
              <Link to="/profile">Profile</Link>
            </li>
            {user?.role === "admin" && (
              <li>
                <Link to="/admin">Admin Dashboard</Link>
              </li>
            )}
          </>
        ) : (
          <li>
            <Link to="/login">Login</Link>
          </li>
        )}
      </ul>
    </nav>
  );
}

In this example:

  1. We create a context for authentication state
  2. We provide user info and functions to login/logout
  3. Multiple components at different levels can access the auth state
  4. Components re-render when the auth state changes
  5. We handle the "no provider" case in our custom hook

Common useContext Patterns

Theme Switching

function App() {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <div className={`app ${theme}-theme`}>{/* App content */}</div>
    </ThemeContext.Provider>
  );
}

Localization/Internationalization

function App() {
  const [locale, setLocale] = useState("en");
  const translations = getTranslations(locale);

  return (
    <LocaleContext.Provider value={{ locale, setLocale, t: translations }}>
      {/* App content */}
    </LocaleContext.Provider>
  );
}

// Usage
function Greeting() {
  const { t } = useContext(LocaleContext);
  return <h1>{t.greeting}</h1>;
}

Feature Flags

function App() {
  const features = {
    newDashboard: process.env.ENABLE_NEW_DASHBOARD === "true",
    betaFeatures: process.env.ENABLE_BETA === "true",
  };

  return <FeaturesContext.Provider value={features}>{/* App content */}</FeaturesContext.Provider>;
}

// Usage
function Dashboard() {
  const { newDashboard } = useContext(FeaturesContext);

  return newDashboard ? <NewDashboard /> : <LegacyDashboard />;
}

Limitations of useContext

While useContext is powerful, it has some important limitations:

  1. Performance concerns: All components that use a context will re-render when the context value changes, even if they only use a small part of the context.
  2. No built-in state management: Context is just a way to pass data down; it doesn't include state management patterns like actions, reducers, or selectors.
  3. No optimization mechanisms: Unlike some state management libraries, Context doesn't have built-in memoization or ways to prevent unnecessary re-renders.
  4. No middleware support: For async operations, logging, or other side effects, you need to implement these yourself.
  5. No dev tools: There are no dedicated dev tools for debugging context changes.

Better Alternatives to useContext for Global State

While useContext is appropriate for certain types of application-wide settings (like themes, locale, or authentication), it's often not the best choice for complex global state management. Here are better alternatives:

Zustand

Zustand has become a popular alternative due to its simplicity and performance:

import create from "zustand";

// Create a store
const useStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}));

// Use in components
function BearCounter() {
  const bears = useStore((state) => state.bears);
  return <h1>{bears} around here...</h1>;
}

function Controls() {
  const { increasePopulation, removeAllBears } = useStore();

  return (
    <div>
      <button onClick={increasePopulation}>More bears</button>
      <button onClick={removeAllBears}>Remove all</button>
    </div>
  );
}

Why Zustand is often better than Context:

  • Simpler API with less boilerplate
  • Built-in performance optimizations
  • Devtools support
  • Middleware for persisting state, logging, etc.
  • No need for providers
  • More flexible subscription model (only subscribe to what you need)

Other Alternatives

  • Redux: More structured but more verbose, good for complex applications
  • Jotai: Atomic state management with a React-like API
  • Recoil: Facebook's experimental state management library with a focus on derived state
  • MobX: Observable state management with automatic tracking
  • XState: State machines for complex state logic

When to Use useContext vs. Alternatives

Use Context for:

  • Theme/UI preferences
  • Current user/authentication state
  • Localization settings
  • Feature flags/application configuration
  • Form state within a multi-step form

Use Zustand or other state management for:

  • Application-wide data cache
  • Shopping carts
  • Complex workflows with many state transitions
  • Data that needs to be shared across many distant components
  • State that requires middleware, persistence, or complex synchronization

Best Practices for useContext

If you do use Context, follow these best practices:

  1. Split contexts by domain: Create separate contexts for unrelated concerns rather than one giant app context.
  2. Keep context values stable: Memoize objects and functions in context values to prevent unnecessary re-renders.
  3. Use custom hooks: Create a custom hook for each context to ensure proper usage and error handling.
  4. Don't nest providers too deeply: Excessive nesting can lead to "provider hell" and make it harder to debug.
  5. Consider component composition: Sometimes passing JSX as children or props is cleaner than using context.

Remember

  • Context is primarily a way to avoid prop drilling, not a complete state management solution
  • Context causes all consumers to re-render when the context value changes
  • For complex global state needs, consider Zustand or other state management libraries
  • Split contexts by concern instead of creating a single global context
  • Use TypeScript to ensure type safety in your contexts