A Beginner-Friendly Guide to the Context API in React
ReactContext APIState Management

A Beginner-Friendly Guide to the Context API in React

January 29, 2025
10 min read
Salman Izhar

A Beginner-Friendly Guide to the Context API in React

You've built a few React components. Things are going well.

Then you need to pass some data from your top-level component down to a component buried three levels deep.

So you pass it as a prop. Then pass it again. And again.

jsx
<App user={user}>
  <Dashboard user={user}>
    <Sidebar user={user}>
      <UserMenu user={user} />
    </Sidebar>
  </Dashboard>
</App>

The components in between don't even USE that data. They're just passing it along like a game of hot potato.

This is called prop drilling. And it's annoying.

That's exactly what the Context API solves.

The Prop Drilling Problem

Let me show you why this gets messy fast.

Say you're building a theme switcher. You need theme data in your Header (for the logo color) and in your Footer (for background color).

Without Context:

jsx
function App() {
  const [theme, setTheme] = useState('light')

  return (
    <div>
      <Header theme={theme} setTheme={setTheme} />
      <Main theme={theme} />
      <Footer theme={theme} />
    </div>
  )
}

function Main({ theme }) {
  return (
    <div>
      <Sidebar theme={theme} />
      <Content theme={theme} />
    </div>
  )
}

function Sidebar({ theme }) {
  return (
    <div>
      <Nav theme={theme} />
    </div>
  )
}

// Nav finally uses it
function Nav({ theme }) {
  return <nav className={theme}>...</nav>
}

Look at all that passing! Main and Sidebar don't care about theme. They're just middlemen.

Now imagine you need to add more data. User info. Language preference. Notifications.

Every component becomes a prop relay station.

How Context API Works

Context is like a broadcast system. Instead of passing data through every level, you broadcast it once. Any component can tune in and listen.

Here's the same theme example with Context:

jsx
import { createContext, useContext, useState } from 'react'

// 1. Create a context
const ThemeContext = createContext()

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

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

// 3. Create a hook for easy access
function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider')
  }
  return context
}

// 4. Wrap your app
function App() {
  return (
    <ThemeProvider>
      <Header />
      <Main />
      <Footer />
    </ThemeProvider>
  )
}

// 5. Use it anywhere, without prop drilling!
function Nav() {
  const { theme } = useTheme()
  return <nav className={theme}>...</nav>
}

function ThemeToggle() {
  const { theme, setTheme } = useTheme()
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Toggle theme
    </button>
  )
}

See the difference? Main and Sidebar completely disappear from the data flow. Only the components that actually need the theme access it directly.

Building Your First Context: A Real Example

Let's build something practical-a shopping cart context.

Step 1: Create the Context

jsx
import { createContext, useContext, useState } from 'react'

const CartContext = createContext()

Simple. This creates an empty context object.

Step 2: Build the Provider

This is where your state lives.

jsx
function CartProvider({ children }) {
  const [items, setItems] = useState([])

  const addItem = (product) => {
    setItems(prev => {
      // Check if item already exists
      const existing = prev.find(item => item.id === product.id)

      if (existing) {
        // Increase quantity
        return prev.map(item =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        )
      }

      // Add new item
      return [...prev, { ...product, quantity: 1 }]
    })
  }

  const removeItem = (productId) => {
    setItems(prev => prev.filter(item => item.id !== productId))
  }

  const updateQuantity = (productId, quantity) => {
    if (quantity <= 0) {
      removeItem(productId)
      return
    }

    setItems(prev =>
      prev.map(item =>
        item.id === productId ? { ...item, quantity } : item
      )
    )
  }

  const clearCart = () => setItems([])

  const total = items.reduce((sum, item) => {
    return sum + (item.price * item.quantity)
  }, 0)

  const itemCount = items.reduce((count, item) => count + item.quantity, 0)

  const value = {
    items,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    total,
    itemCount,
  }

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

This provider:

  • Holds the cart state
  • Provides functions to modify it
  • Calculates derived values (total, itemCount)
  • Makes everything available through value

Step 3: Create a Custom Hook

This makes the API nicer to use.

jsx
function useCart() {
  const context = useContext(CartContext)

  if (context === undefined) {
    throw new Error('useCart must be used within CartProvider')
  }

  return context
}

The error check is important. It catches mistakes where you forget to wrap components in the provider.

Step 4: Wrap Your App

jsx
function App() {
  return (
    <CartProvider>
      <Header />
      <ProductList />
      <Cart />
    </CartProvider>
  )
}

Everything inside CartProvider can now access the cart.

Step 5: Use It Anywhere

jsx
function ProductCard({ product }) {
  const { addItem } = useCart()

  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => addItem(product)}>
        Add to Cart
      </button>
    </div>
  )
}

function CartButton() {
  const { itemCount } = useCart()

  return (
    <button>
      Cart ({itemCount})
    </button>
  )
}

function CartSummary() {
  const { items, total, removeItem } = useCart()

  return (
    <div>
      {items.map(item => (
        <div key={item.id}>
          {item.name} x {item.quantity}
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </div>
      ))}
      <div>Total: ${total}</div>
    </div>
  )
}

Look how clean this is! No props. No drilling. Just direct access to cart data and functions.

Another Example: Authentication Context

This is probably the most common use case for Context.

jsx
const AuthContext = createContext()

function AuthProvider({ children }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)

  // Check if user is logged in on mount
  useEffect(() => {
    const checkAuth = async () => {
      try {
        const response = await fetch('/api/me')
        if (response.ok) {
          const userData = await response.json()
          setUser(userData)
        }
      } catch (error) {
        console.error('Auth check failed:', error)
      } finally {
        setLoading(false)
      }
    }

    checkAuth()
  }, [])

  const login = async (email, password) => {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })

    if (response.ok) {
      const userData = await response.json()
      setUser(userData)
      return { success: true }
    }

    return { success: false, error: 'Invalid credentials' }
  }

  const logout = async () => {
    await fetch('/api/logout', { method: 'POST' })
    setUser(null)
  }

  const value = {
    user,
    loading,
    login,
    logout,
    isAuthenticated: !!user,
  }

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

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

// Usage
function LoginButton() {
  const { isAuthenticated, logout } = useAuth()

  if (isAuthenticated) {
    return <button onClick={logout}>Logout</button>
  }

  return <button>Login</button>
}

function Profile() {
  const { user, loading } = useAuth()

  if (loading) return <div>Loading...</div>
  if (!user) return <div>Please log in</div>

  return <div>Welcome, {user.name}!</div>
}

Now any component can check auth status or trigger login/logout. No prop drilling required.

Best Practices

1. One Context, One Purpose

Don't dump everything into a single context.

Bad:

jsx
const AppContext = createContext() // user, theme, cart, everything!

Good:

jsx
const AuthContext = createContext()
const ThemeContext = createContext()
const CartContext = createContext()

2. Always Create a Custom Hook

Makes the API cleaner and catches mistakes.

jsx
function useCart() {
  const context = useContext(CartContext)
  if (!context) {
    throw new Error('useCart must be used within CartProvider')
  }
  return context
}

3. Keep Provider Logic Clean

If your provider gets complex, extract logic to custom hooks.

jsx
function CartProvider({ children }) {
  const cart = useCartLogic() // Custom hook with all the logic

  return (
    <CartContext.Provider value={cart}>
      {children}
    </CartContext.Provider>
  )
}

4. Don't Overuse Context

Context is great for:

  • Theme data
  • User authentication
  • Language/locale
  • Global UI state (modals, notifications)

Context is NOT great for:

  • Data that changes frequently
  • Data only used by a few components
  • Complex state logic (use Zustand or Redux instead)

Common Mistakes to Avoid

Mistake 1: Forgetting the Provider

jsx
// This will error!
function MyComponent() {
  const { theme } = useTheme() // Error: used outside provider
  return <div>...</div>
}

Always wrap your app (or the part that needs it) in the provider.

Mistake 2: Creating New Objects in Value

jsx
// Bad - creates new object on every render
<ThemeContext.Provider value={{ theme, setTheme }}>

Every render creates a new object, causing all consumers to re-render.

Fix: Use useMemo or put it in state
jsx
const value = useMemo(() => ({ theme, setTheme }), [theme])

<ThemeContext.Provider value={value}>

Mistake 3: Too Many Re-renders

If your context value changes often, all consumers re-render.

Solution: Split contexts or use state management libraries for frequently-changing data.

When Context Clicks

I remember building my first real app with Context. A dashboard with user data, settings, and notifications.

Before Context, I was passing props through 7 components. It was a nightmare to refactor.

With Context:

jsx
<AuthProvider>
  <SettingsProvider>
    <NotificationProvider>
      <Dashboard />
    </NotificationProvider>
  </SettingsProvider>
</AuthProvider>

Any component could access what it needed. Adding new features became easy. The code felt clean again.

That's when Context clicked for me.

The Bottom Line

Context API solves prop drilling. That's its job.

It's not a full state management solution. It's not Redux. It's not Zustand.

It's a way to share data without passing props.

For theme, auth, and UI state? Perfect.

For complex app state? Consider other options.

But for most projects, Context is exactly what you need.

---

Want to see Context in action? Check out my guide on building authentication in Next.js using Context API.
Get More Like This

Want articles like this in your inbox?

Join developers and founders who get practical insights on frontend, SaaS, and building better products.

S

Written by Salman Izhar

Frontend Developer specializing in React, Next.js, and building high-converting web applications.

Learn More