Imagine three components sitting side by side - one shows a number, one has buttons to change it, and one shows a message based on it.
![Rikam Palkar Context API 3]()
![Rikam Palkar Context API]()
They're siblings: no parent, no hierarchy, How does clicking a button in one component update what the other two display?
See how clicking the +1 / -1 buttons in <CounterControls> updates both <CounterDisplay> and <CounterStatus> at the same time.
![Rikam Palkar Context API output]()
Find all code in this GitHub repo: react-ts-simplified
The plan before any code
We need four things:
Shared state: the variable counter that multiple components can read
A way to change it: named actions like increment, decrement, reset, incrementBy
A provider: a wrapper component that makes the shared data available to everything inside it
A hook: a clean function components call to access that data
That gives us a natural split into three store files:
| File | Responsibility |
|---|
counter-context-value.ts | Types and the context object |
counter-context.tsx | Owns the state, runs updates, wraps children |
use-counter-context.ts | Gives components a safe way in |
And four component files:
| File | What it does |
|---|
CounterDisplay.tsx | Reads and shows the count |
CounterControls.tsx | Buttons to change the count |
CounterStatus.tsx | A message derived from the count |
CounterContextDemo.tsx | Composes all three under the provider |
Your file structure should look like this:
src
├── components
│ └── context
│ └── counter
│ ├── CounterContextDemo.tsx
│ ├── CounterControls.tsx
│ ├── CounterDisplay.tsx
│ └── CounterStatus.tsx
└── store
└── counter
├── counter-context-value.ts
├── counter-context.tsx
└── use-counter-context.ts
Leave the files empty for now. We'll fill them one by one.
Step 1: Describe your data before writing any logic - counter-context-value.ts
The first question is always: what does the shared data look like? Before a single component reads anything, you need to define the shape. This is why types come first.
Open src/store/counter/counter-context-value.ts
The state shape
The state is simple: just a count:
export type CounterState = {
count: number;
};
The actions
Components don't change state directly. They do it through an action . Like ordering at a restaurant: you tell the waiter what you want, and the kitchen decides how to make it.
Each action has a type (what to do) and sometimes a payload (extra info it needs to carry). The name payload is just a community convention, it's whatever data the action needs to bring along for the ride. The first three are self-contained. The fourth needs to know how much, so it carries a number.
export type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| { type: 'incrementBy'; payload: number };
What components receive from context
Components need two things: the current count to display, and a way to trigger change, which is what we calldispatch.
What is Dispatch?
It is React's built-in type for the function that sends an action to a reducer. When a component calls dispatch({ type: 'increment' }), it is posting a message saying "Perform increment." The reducer receives that message and computes new state. The component never touches state directly, it only sends messages.
So as per our analogy,dispatch would be waiter. You hand it an action, it delivers it to the (Chef)reducer, and the reducer updates state. Dispatch<CounterAction> ensures you can only dispatch defined actions.
import { createContext, type Dispatch } from 'react';
export type CounterContextValue = CounterState & {
dispatch: Dispatch<CounterAction>;
};
Create the context object
Finally, create the context object itself. This is what React uses internally to carry the value through the component tree.
The | undefined is there because before the Provider mounts, there's no value yet. We'll handle that in the hook.
export const CounterContext = createContext<CounterContextValue | undefined>(undefined);
Here's the full file:
//src/store/counter/counter-context-value.ts
import { createContext, type Dispatch } from 'react';
export type CounterState = {
count: number;
};
export type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| { type: 'incrementBy'; payload: number };
export type CounterContextValue = CounterState & {
dispatch: Dispatch<CounterAction>;
};
export const CounterContext = createContext<CounterContextValue | undefined>(undefined);
Step 2: Set up state management - counter-context.tsx
You've described what the data looks like. Now you need something that actually holds it and updates it when actions arrive. That's the Provider's job.
Why useReducer instead of useState?
You could use useState here, and for a single value it'd work fine. But useReducer is the better fit when multiple actions can change the same state and you want all that logic in one place. useState is good for local state. For shared state across components, useReducer works better.
The reducer
A reducer is a pure function. It takes the current state and an action, and returns the next state. It never mutates, it always returns a new object.
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 };
case 'decrement':
return { ...state, count: state.count - 1 };
case 'reset':
return initialState;
case 'incrementBy':
return { ...state, count: state.count + action.payload };
default:
return state;
}
}
The Provider component
Now wrap useReducer in a component that shares its result through context:
export function CounterProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<CounterContext.Provider value={{ ...state, dispatch }}>
{children}
</CounterContext.Provider>
);
}
useReducer returns two things: the current state and the dispatch function.
Here's the full file:
//src/store/counter/counter-context.tsx
import { useReducer, type ReactNode } from 'react';
import { CounterContext, type CounterAction, type CounterState } from './counter-context-value';
const initialState: CounterState = {
count: 0,
};
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 };
case 'decrement':
return { ...state, count: state.count - 1 };
case 'reset':
return initialState;
case 'incrementBy':
return { ...state, count: state.count + action.payload };
default:
return state;
}
}
export function CounterProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<CounterContext.Provider value={{ ...state, dispatch }}>
{children}
</CounterContext.Provider>
);
}
State lives here. Reducer lives here. Provider lives here. Components know nothing about any of this. they just call a hook.
Step 3: Give components a clean door into the context - use-counter-context.ts
Any component could call useContext(CounterContext) directly. So why write a separate hook?
Because every component that calls useContext directly has to:
Import CounterContext
Handle the undefined case themselves
Duplicate that guard logic in every single component
That is three things per component, all of which could be wrong in different ways. Instead, you write one hook that does it once.
So when you hear custom hook here, do not think of something magical. It is just your own reusable function built on top of React hooks.
Open src/store/counter/use-counter-context.ts:
//src/store/counter/use-counter-context.ts
import { useContext } from 'react';
import { CounterContext } from './counter-context-value';
export function useCounterContext() {
const context = useContext(CounterContext);
if (!context) {
throw new Error('useCounterContext must be used inside CounterProvider');
}
return context;
}
The if (!context) check catches the classic mistake of using the hook outside the Provider. Instead of a silent crash somewhere deep in a component, you get a clear, immediate error pointing right at what went wrong.
From here on, every component just calls useCounterContext() and gets back { count, dispatch } typed, validated, and safe.
Step 4: Build the display component - CounterDisplay.tsx
The first consumer is the simplest one: it reads count and renders it.
Open src/components/context/counter/CounterDisplay.tsx:
import { useCounterContext } from '../../../store/counter/use-counter-context';
export default function CounterDisplay() {
const { count } = useCounterContext();
return (
<section>
<h2>Shared Count</h2>
<p style={{ fontSize: '2rem', margin: 0 }}>{count}</p>
</section>
);
}
This component has no state of its own. The number it shows comes entirely from context. When context changes, React re-renders it automatically — no manual wiring needed.
Step 5: Build the controls component - CounterControls.tsx
This component is the mirror of CounterDisplay. It doesn't need to know what the count is , it just needs to send actions when buttons are clicked.
Open src/components/context/counter/CounterControls.tsx:
import { useCounterContext } from '../../../store/counter/use-counter-context';
export default function CounterControls() {
const { dispatch } = useCounterContext();
return (
<section>
<h2>Actions</h2>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'incrementBy', payload: 5 })}>+5</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
</section>
);
}
Look at dispatch({ type: 'incrementBy', payload: 5 }). The component passes along 5 but does zero math. The reducer handles the math. This is a clean split of responsibilities:
The button says what happened
The action carries any extra value needed
The reducer decides the next state
Step 6: Build a component that derives meaning from state - CounterStatus.tsx
CounterStatus shows something powerful: you can compute UI from state without storing anything extra. It reads count, applies some logic, and shows a message.
Open src/components/context/counter/CounterStatus.tsx:
import { useCounterContext } from '../../../store/counter/use-counter-context';
export default function CounterStatus() {
const { count } = useCounterContext();
let message = 'Count is zero.';
if (count > 0) message = 'Count is positive.';
if (count < 0) message = 'Count is negative.';
return (
<section>
<h2>Status</h2>
<p>{message}</p>
</section>
);
}
Every time count changes, this component re-renders and recomputes message. It's always accurate because it never stores its own copy of anything.
The more honest way to write that note would be something like:
This component has no state of its own. The number it shows comes from context — and whenever the context value updates, React re-renders all components consuming it, CounterDisplay, CounterControls, and CounterStatus.
Step 7: Wrap them all under one provider - CounterContextDemo.tsx
You now have three components that each read from or write to context. But context doesn't exist until a Provider is mounted above them in the tree.
Open src/components/context/counter/CounterContextDemo.tsx:
import CounterControls from './CounterControls';
import CounterDisplay from './CounterDisplay';
import CounterStatus from './CounterStatus';
import { CounterProvider } from '../../../store/counter/counter-context';
export default function CounterContextDemo() {
return (
<CounterProvider>
<div style={{ display: 'grid', gap: '1rem' }}>
<h1>Counter Context API Demo</h1>
<CounterDisplay />
<CounterControls />
<CounterStatus />
</div>
</CounterProvider>
);
}
One wrapper on top, three siblings below, all sharing the same counter. When CounterControls dispatches an action, the Provider updates and hands fresh context to CounterDisplay and CounterStatus, which re-render automatically.
No props passed between siblings. The provider is their shared memory.
The flow:
You click +5. Here's exactly what happens:
CounterControls calls dispatch({ type: 'incrementBy', payload: 5 })
React sends that action to counterReducer
The reducer matches 'incrementBy', reads action.payload (5), returns { count: state.count + 5 }
CounterProvider's state updates
React re-renders every component reading from the context
CounterDisplay shows the new number
CounterStatus recomputes and shows the new message
No component talked to another component. No props changed. The reducer handled the logic. The provider handled the distribution.
The order that makes this click
If Context API ever felt confusing, it's probably because the pieces showed up without explanation of why they're ordered the way they are. Now you know:
Types first: every other file depends on them
Provider second: it owns the state; the reducer lives here
Hook third: one clean, validated entry point for components
Components last: by this point they just call the hook and trust it
Find all code in this GitHub repo: react-ts-simplified
Hope you liked it. Front end still has its place in the world of AI, someone needs to know what AI is writing, else how would you even debug?
Cheers,
Rikam - Microsoft MVP