DEV Community

Atilla Baspinar
Atilla Baspinar

Posted on

04 - React: Context and Reducers

React notes: context and reducer

This is not a full React tutorial. It focuses on two patterns that are commonly used together in real applications: Context and useReducer.

1) Context

Context is used to share data across multiple components without passing props through every level of the tree.

A common example is a shopping cart context.

import { createContext } from 'react';

type CartItem = {
  id: number;
  name: string;
  quantity: number;
};

const CartContext = createContext<{ items: CartItem[] }>({
  items: [],
});
Enter fullscreen mode Exit fullscreen mode

The object passed to createContext() is the default value. This is only used when a component reads the context without a matching provider.

Provider

The provider is where we provide the actual value for the tree below it.

<CartContext.Provider value={{ items: cartItems }}>
  <CartDetails />
</CartContext.Provider>
Enter fullscreen mode Exit fullscreen mode

This value is not the default value. It is the live value React will expose to consuming components in the subtree.

Consuming the context

In a child component, we can read the value with useContext.

import { useContext } from 'react';

function CartDetails() {
  const cartCtx = useContext(CartContext);

  return <div>{cartCtx.items.length} items in cart</div>;
}
Enter fullscreen mode Exit fullscreen mode

This is the standard pattern. The hook can be called at the top level of the component function.

In React 19, there is also a newer use(context) pattern available, but useContext is still the most common and easy to understand approach.

import { use } from 'react';

function CartDetails() {
  const cartCtx = use(CartContext);

  return <div>{cartCtx.items.length} items in cart</div>;
}
Enter fullscreen mode Exit fullscreen mode

The important idea is the same: the component reads the current context value from the nearest provider.

Older pattern: Context.Consumer

Before hooks were common, React used a consumer pattern:

<CartContext.Consumer>
  {(cartCtx) => {
    return <div>{cartCtx.items.length} items in cart</div>;
  }}
</CartContext.Consumer>
Enter fullscreen mode Exit fullscreen mode

This works, but it is more verbose. useContext is the modern and simpler approach.

2) Context value should usually be connected to state

The value of the context is usually tied to some state in the parent component.

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

const CartContext = createContext<{
  items: { id: number; name: string }[];
  addItem: (name: string) => void;
}>({
  items: [],
  addItem: () => {},
});

export default function App() {
  const [items, setItems] = useState<{ id: number; name: string }[]>([]);

  const addItem = (name: string) => {
    setItems((prev) => [
      ...prev,
      { id: Date.now(), name },
    ]);
  };

  const ctxValue = {
    items,
    addItem,
  };

  return (
    <CartContext.Provider value={ctxValue}>
      <CartDetails />
    </CartContext.Provider>
  );
}

function CartDetails() {
  const cartCtx = useContext(CartContext);

  return (
    <>
      <div>Total items: {cartCtx.items.length}</div>
      <button onClick={() => cartCtx.addItem('Apple')}>Add item</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the shared value is linked to the component state. When state changes, all consumers of that context re-render.

This is one of the most common ways to manage shared application data in React.

3) Reducer

A reducer is a way to manage more complex state updates in a predictable way.

Instead of writing several setState calls with conditional logic, we define a reducer function that receives the current state and an action, and returns the next state.

useReducer also returns a state value and a dispatch function, similar to useState, but with one important difference: the update logic is centralized in a reducer function instead of being spread across multiple event handlers.

import { useReducer } from 'react';

type Action =
  | { type: 'add'; item: string }
  | { type: 'remove'; id: number }
  | { type: 'clear' };

function cartReducer(state: string[], action: Action) {
  switch (action.type) {
    case 'add':
      return [...state, action.item];
    case 'remove':
      return state.filter((_, index) => index !== action.id);
    case 'clear':
      return [];
    default:
      return state;
  }
}

export default function App() {
  const [items, dispatch] = useReducer(cartReducer, ['Apple']);

  return (
    <>
      <button onClick={() => dispatch({ type: 'add', item: 'Orange' })}>
        Add Orange
      </button>
      <button onClick={() => dispatch({ type: 'clear' })}>Clear</button>
      <ul>
        {items.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is useful when the state can change in several ways and those changes are easier to reason about in one place. The reducer acts like a switchboard for state updates: the action tells it what happened, and the reducer decides the new state.

The reducer pattern is especially helpful when state updates are more than a simple assignment. It keeps the logic centralized and makes the flow easier to follow.

4) Context + reducer together

A very common pattern is to use a reducer inside a provider component and expose actions through context.

import { createContext, useContext, useReducer } from 'react';

type Item = { id: number; name: string };

type State = {
  items: Item[];
};

type Action =
  | { type: 'add'; item: string }
  | { type: 'remove'; id: number };

function cartReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':
      return {
        items: [
          ...state.items,
          { id: Date.now(), name: action.item },
        ],
      };
    case 'remove':
      return {
        items: state.items.filter((item) => item.id !== action.id),
      };
    default:
      return state;
  }
}

const CartContext = createContext<{
  items: Item[];
  addItem: (name: string) => void;
  removeItem: (id: number) => void;
} | null>(null);

export default function App() {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });

  const value = {
    items: state.items,
    addItem: (name: string) => dispatch({ type: 'add', item: name }),
    removeItem: (id: number) => dispatch({ type: 'remove', id }),
  };

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

function CartView() {
  const cartCtx = useContext(CartContext);

  if (!cartCtx) {
    return null;
  }

  return (
    <>
      {cartCtx.items.map((item) => (
        <button key={item.id} onClick={() => cartCtx.removeItem(item.id)}>
          {item.name}
        </button>
      ))}
      <button onClick={() => cartCtx.addItem('Tea')}>Add Tea</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is a very common application structure:

  • state is stored in a reducer
  • actions describe what happened
  • the provider exposes the current state and update functions
  • child components consume the context without prop drilling

5) Summary

  • Context shares data across components without manually passing props through many layers
  • The provider supplies the actual value; the default value is only a fallback
  • useContext is the standard way to read context in functional components
  • useReducer helps manage more complex state updates in a predictable, centralized way
  • Context and reducer are often used together for app-level state like carts, auth, settings, and forms

A good rule of thumb is:

  • use context for sharing data
  • use reducer for managing state transitions

Together, they form a very solid pattern for building medium-size React apps.

Top comments (0)