TypeScript  

Stop Using Context Like This - Build a Hook Instead

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 3Rikam 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:

FileResponsibility
counter-context-value.tsTypes and the context object
counter-context.tsxOwns the state, runs updates, wraps children
use-counter-context.tsGives components a safe way in

And four component files:

FileWhat it does
CounterDisplay.tsxReads and shows the count
CounterControls.tsxButtons to change the count
CounterStatus.tsxA message derived from the count
CounterContextDemo.tsxComposes 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:

  1. Import CounterContext

  2. Handle the undefined case themselves

  3. 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:

  1. CounterControls calls dispatch({ type: 'incrementBy', payload: 5 })

  2. React sends that action to counterReducer

  3. The reducer matches 'incrementBy', reads action.payload (5), returns { count: state.count + 5 }

  4. CounterProvider's state updates

  5. React re-renders every component reading from the context

  6. CounterDisplay shows the new number

  7. 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:

  1. Types first: every other file depends on them

  2. Provider second: it owns the state; the reducer lives here

  3. Hook third: one clean, validated entry point for components

  4. 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