State Management: Context API

So far, every piece of state you've seen has lived inside a single component with useState (see State & Hooks), and any data a component needed came in through props from its parent. That works fine for small trees of components. But as an app grows — a logged-in user's identity, a selected theme, an active language — you'll often have a piece of data that dozens of components, scattered at every depth of the tree, all need to read. Passing that data down through props alone quickly becomes painful. This lesson covers React's built-in Context API, the standard solution for sharing data across a component tree without threading it through every layer by hand.

The Problem: Prop Drilling

Prop drilling is the name for what happens when you pass a piece of data down through several layers of components purely so it can reach a deeply nested child — even though every component in between has no actual use for that data itself. Here's a realistic (if slightly exaggerated) example: a theme value that needs to reach a SubmitButton four components deep.

// BAD: theme has to be passed through every level, even though
// App, Screen, and Form never actually use it themselves

function App() {
  const theme = 'dark';
  return <Screen theme={theme} />;
}

function Screen({ theme }) {
  // Screen doesn't use theme, just forwards it
  return <Form theme={theme} />;
}

function Form({ theme }) {
  // Form doesn't use theme either, just forwards it again
  return <SubmitButton theme={theme} />;
}

function SubmitButton({ theme }) {
  // finally, theme is actually used here
  return (
    <Button
      title="Submit"
      color={theme === 'dark' ? '#ffffff' : '#000000'}
    />
  );
}

Every component between App and SubmitButton had to know about theme just to relay it — Screen and Form are pure middlemen. In a real app, add a second and third piece of shared data (current user, selected language) and every one of those components needs even more pass-through props. Renaming a prop means touching every level. This is exactly the problem Context solves.

Creating a Context

React.createContext() creates a "channel" that any component in the tree can tap into, regardless of how deeply it's nested, without an unbroken chain of props connecting it to the source:

import React from 'react';

const ThemeContext = React.createContext();

ThemeContext is just a plain JavaScript value at this point — creating it doesn't share anything yet. Sharing happens in two steps: a Provider makes a value available to the tree beneath it, and a consumer (via the useContext hook) reads that value from anywhere inside.

Providing a Value at the Top of the Tree

Every context object comes with a .Provider component. Wrap it around the part of your tree that should have access to the value, and pass the actual value through its value prop:

function App() {
  const theme = 'dark';

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

function Screen() {
  // no theme prop needed at all anymore
  return <Form />;
}

function Form() {
  return <SubmitButton />;
}

Notice Screen and Form no longer mention theme at all — the middleman problem is gone. Only the component that actually needs the value has to ask for it.

Consuming a Context With useContext

Any descendant of the Provider — no matter how many levels down — can read the current value with the useContext hook:

import React, { useContext } from 'react';
import { Button } from 'react-native';

function SubmitButton() {
  const theme = useContext(ThemeContext);

  return (
    <Button
      title="Submit"
      color={theme === 'dark' ? '#ffffff' : '#000000'}
    />
  );
}

This same pattern is exactly how you'd share a logged-in user's identity across an app — create an AuthContext, provide the current user object once near the root, and read it with useContext(AuthContext) from any screen that needs to show the user's name, or decide whether to render a "Sign in" versus "Sign out" option — with zero prop threading through every navigator and screen in between.

const AuthContext = React.createContext();

// near the root of the app
<AuthContext.Provider value={{ id: 'u_42', name: 'Sonu' }}>
  <RootNavigator />
</AuthContext.Provider>

// anywhere deep inside RootNavigator
function ProfileScreen() {
  const user = useContext(AuthContext);
  return <Text>Welcome back, {user.name}</Text>;
}

Making Context Updatable: Combining useState With Context

Everything so far shared a value that never changed after the app started. Real apps usually need shared state that updates over time — a shopping cart is a good example. The trick is simple: put a useState call inside the component that renders the Provider, and pass both the state value and a function to update it through value.

import React, { createContext, useState } from 'react';

const CartContext = createContext();

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

  function addItem(product) {
    setItems(current => [...current, product]);
  }

  return (
    <CartContext.Provider value={{ items, addItem }}>
      {children}
    </CartContext.Provider>
  );
}

export default CartProvider;
export { CartContext };

CartProvider wraps the part of the app that needs cart access — typically near the root, alongside or above your navigator:

function App() {
  return (
    <CartProvider>
      <RootNavigator />
    </CartProvider>
  );
}

Now any screen, however deeply nested, can both read the cart and update it, without a single prop passed down:

import React, { useContext } from 'react';
import { View, Text, Button } from 'react-native';
import { CartContext } from './CartProvider';

function ProductScreen() {
  const { items, addItem } = useContext(CartContext);

  return (
    <View>
      <Text>Cart has {items.length} item(s)</Text>
      <Button
        title="Add running shoes"
        onPress={() => addItem({ id: 'p_1', name: 'Running Shoes' })}
      />
    </View>
  );
}

Every call to addItem updates items inside CartProvider, which re-renders the Provider with a fresh value — and every component consuming CartContext automatically re-renders with the new data. This is the same useState mechanics from State & Hooks; Context just widens who's allowed to see and trigger the update.

When Context Is the Right Tool — and When It's Overkill

Context is convenient, but it isn't free. Every time the value passed to a Provider changes, every component consuming that context re-renders — even if it only cares about one small piece of a larger value object. For data that changes rarely, this is a non-issue. For data that changes constantly, it can quietly become a performance problem.

SituationIs Context a good fit?
Theme (light/dark)Yes — changes rarely, read by many components
Logged-in user / auth statusYes — changes rarely (login/logout), read broadly
Selected language / localeYes — changes rarely, read broadly
Text typed into a search box, updating on every keystrokeNo — changes very frequently; every keystroke would re-render every consumer
A large, frequently-updated shopping cart or live data feed in a big appUsually not ideal — works for small apps, but can cause broad re-renders as it scales
Note: Context is a built-in distribution mechanism — a way to make a value reachable without prop drilling — not a full state-management library. It has no concept of "select only the piece of state this component cares about," no middleware, and no dev tools. For state that changes often, or state shared across a genuinely large app, purpose-built tools like Redux Toolkit or Zustand (covered next) solve the re-render problem more precisely.
Next up: Context is great for simple, infrequently-changing shared state. The next lesson covers Redux & Zustand — dedicated state-management libraries built for larger apps and state that changes constantly.