React's Context API & useReducer — Advanced State ManagementEntry 4 of 7
Linking Context to State (Outsourced Provider Component)
A dedicated component that owns the state, builds the context value (data plus functions), and wraps its children in the provider.
Notes
Context becomes powerful when its value comes from state. The value can hold both data (items) and functions that update it (addItem, updateItemQuantity), so any consumer can read and change the shared state.
Keeping that state in App clutters it. The clean pattern is to outsource it:
- Move the state and handlers into a
CartContextProvidercomponent. - Build the context value there and render
childreninside the provider. - Export a custom hook (
useCart) so consumers don't import the context directly. - Wrap the app (or a subtree) in
CartContextProvider.
With TypeScript, prefer a null default plus a guard in the custom hook over a dummy default value. A missing provider then throws a clear error instead of silently using no-op functions.
What / Why / When
- What
State + context in one self-contained module.
- Why
Appstays clean, the state logic lives in one place, and consumers get a simple typed API.- When
Any context that holds changing data.
- Use case
A cart store used by
Product(add items) andCart(display and change quantities).
Code compare
// store/cart-context.tsximport { createContext, useContext, useState, type ReactNode } from "react";​export type CartItem = { id: string; name: string; price: number; quantity: number };​type CartContextValue = { items: CartItem[]; addItem: (product: Omit<CartItem, "quantity">) => void; updateItemQuantity: (id: string, amount: number) => void;};​const CartContext = createContext<CartContextValue | null>(null);​export function CartContextProvider({ children }: { children: ReactNode }) { const [items, setItems] = useState<CartItem[]>([]);​ function addItem(product: Omit<CartItem, "quantity">) { setItems((prev) => { const existing = prev.find((item) => item.id === product.id); if (existing) { return prev.map((item) => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item, ); } return [...prev, { ...product, quantity: 1 }]; }); }​ function updateItemQuantity(id: string, amount: number) { setItems((prev) => prev .map((item) => (item.id === id ? { ...item, quantity: item.quantity + amount } : item)) .filter((item) => item.quantity > 0), ); }​ const ctxValue: CartContextValue = { items, addItem, updateItemQuantity };​ return <CartContext value={ctxValue}>{children}</CartContext>;}​// Custom hook: the only way consumers access the cartexport function useCart() { const ctx = useContext(CartContext); if (!ctx) throw new Error("useCart must be used inside <CartContextProvider>"); return ctx;}// main.tsximport { StrictMode } from "react";import { createRoot } from "react-dom/client";import { App } from "./App";import { CartContextProvider } from "./store/cart-context";​createRoot(document.getElementById("root")!).render( <StrictMode> <CartContextProvider> <App /> </CartContextProvider> </StrictMode>,);// Cart.tsximport { useCart } from "../store/cart-context";​export function Cart() { const { items, updateItemQuantity } = useCart(); const totalPrice = items.reduce((sum, item) => sum + item.price * item.quantity, 0);​ if (items.length === 0) return <p>No items in cart.</p>;​ return ( <> <ul> {items.map((item) => ( <li key={item.id}> {item.name} ({item.quantity}) <button onClick={() => updateItemQuantity(item.id, -1)}>-</button> <button onClick={() => updateItemQuantity(item.id, 1)}>+</button> </li> ))} </ul> <p>Total: ${totalPrice.toFixed(2)}</p> </> );}The service holds the state as a private writable signal, exposes a read-only version, and offers methods for changes:
// cart.service.tsimport { Injectable, computed, signal } from "@angular/core";​export type CartItem = { id: string; name: string; price: number; quantity: number };​@Injectable({ providedIn: "root" })export class CartService { private readonly _items = signal<CartItem[]>([]);​ readonly items = this._items.asReadonly(); readonly totalPrice = computed(() => this._items().reduce((sum, item) => sum + item.price * item.quantity, 0), );​ addItem(product: Omit<CartItem, "quantity">) { this._items.update((prev) => { const existing = prev.find((item) => item.id === product.id); if (existing) { return prev.map((item) => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item, ); } return [...prev, { ...product, quantity: 1 }]; }); }​ updateItemQuantity(id: string, amount: number) { this._items.update((prev) => prev .map((item) => (item.id === id ? { ...item, quantity: item.quantity + amount } : item)) .filter((item) => item.quantity > 0), ); }}// cart.component.tsimport { Component, inject } from "@angular/core";import { CurrencyPipe } from "@angular/common";import { CartService } from "./cart.service";​@Component({ selector: "app-cart", imports: [CurrencyPipe], template: ` <ul> @for (item of cart.items(); track item.id) { <li> {{ item.name }} ({{ item.quantity }}) <button (click)="cart.updateItemQuantity(item.id, -1)">-</button> <button (click)="cart.updateItemQuantity(item.id, 1)">+</button> </li> } @empty { <p>No items in cart.</p> } </ul> <p>Total: {{ cart.totalPrice() | currency }}</p> `,})export class CartComponent { protected cart = inject(CartService);}Differences and gotchas
- React needs three pieces (context, provider component, custom hook). Angular needs one: the service.
asReadonly()is Angular's equivalent of exposing only the functions you choose: consumers can readitemsbut must call methods to change it.- Every new render of the React provider creates a new
ctxValueobject, which re-renders all consumers. The React Compiler oruseMemocan stabilize it when that matters.
Quick reference
| React | Angular |
|---|---|
CartContextProvider owning useState | Service owning signal() |
ctxValue = { items, addItem } | Public signals + methods |
useCart() custom hook | inject(CartService) |
null default + throw in hook | NullInjectorError if not provided |