Skip to content

Part 6.4 — Combining Context + useReducer for App-Level State

Site Console Site Console
3 min read Updated Jan 15, 2026 Frontend Design 0 comments

When state needs to be shared widely and transitions matter, neither Context nor useReducer alone is enough.

  • Context solves distribution

  • Reducers solve state transitions

Combined correctly, they give you a clean, Redux-like architecture using only React primitives—no extra libraries, no ceremony.

This post shows how to do it without creating a performance or maintenance trap.


1. When This Architecture Is the Right Choice

Use Context + useReducer when:

  • state is used across many routes

  • updates are driven by events (login, logout, refresh)

  • transitions must be predictable

  • Redux feels heavy—but useState is too weak

Common examples:

  • authentication session

  • feature flags

  • onboarding flows

  • app-wide notifications

If state is local or feature-scoped, stop here. Don’t globalize prematurely.


2. Define the State and Actions First

Start with intent, not code.

Example: authentication state.

type AuthState = {
  status: 'anonymous' | 'authenticated';
  user: User | null;
};

type AuthAction =
  | { type: 'LOGIN_SUCCESS'; user: User }
  | { type: 'LOGOUT' };

This already answers:

  • what can happen

  • what data exists

  • what combinations are valid


3. Write a Pure Reducer

function authReducer(
  state: AuthState,
  action: AuthAction,
): AuthState {
  switch (action.type) {
    case 'LOGIN_SUCCESS':
      return {
        status: 'authenticated',
        user: action.user,
      };

    case 'LOGOUT':
      return {
        status: 'anonymous',
        user: null,
      };

    default:
      return state;
  }
}

No side effects. No async logic. Just transitions.


4. Create Focused Contexts (State vs Actions)

Avoid a single “mega context”.

Create two:

const AuthStateContext =
  React.createContext<AuthState | undefined>(undefined);

const AuthDispatchContext =
  React.createContext<React.Dispatch<AuthAction> | undefined>(
    undefined,
  );

Why split them?

  • state changes frequently

  • dispatch is stable

This dramatically reduces unnecessary re-renders.


5. Build the Provider

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = React.useReducer(authReducer, {
    status: 'anonymous',
    user: null,
  });

  return (
    <AuthStateContext.Provider value={state}>
      <AuthDispatchContext.Provider value={dispatch}>
        {children}
      </AuthDispatchContext.Provider>
    </AuthStateContext.Provider>
  );
}

The provider owns:

  • initialization

  • reducer wiring

  • distribution

Consumers stay simple.


6. Create Safe Consumer Hooks

function useAuthState() {
  const ctx = React.useContext(AuthStateContext);
  if (!ctx) {
    throw new Error('useAuthState must be used within AuthProvider');
  }
  return ctx;
}

function useAuthDispatch() {
  const ctx = React.useContext(AuthDispatchContext);
  if (!ctx) {
    throw new Error('useAuthDispatch must be used within AuthProvider');
  }
  return ctx;
}

This gives you:

  • clear API

  • explicit dependencies

  • safer refactors


7. Using the Architecture in Components

Read state:

const { status, user } = useAuthState();

if (status === 'anonymous') {
  return <LoginForm />;
}

Trigger transitions:

const dispatch = useAuthDispatch();

dispatch({ type: 'LOGOUT' });

Notice:

  • components don’t know how state changes

  • they only describe what happened

This decoupling scales extremely well.


8. Where Async Logic Lives

Async logic does not go in reducers.

Create action helpers:

function useAuthActions() {
  const dispatch = useAuthDispatch();

  async function login(email: string, password: string) {
    const res = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });
    const data = await res.json();
    dispatch({ type: 'LOGIN_SUCCESS', user: data.user });
  }

  function logout() {
    dispatch({ type: 'LOGOUT' });
  }

  return { login, logout };
}

Now:

  • reducers stay pure

  • async logic is centralized

  • UI stays declarative


9. Provider Placement in the Tree

Place providers:

  • as high as necessary

  • as low as possible

Example:

<AuthProvider>
  <Router />
</AuthProvider>

Avoid wrapping providers around parts of the tree that don’t need them.


10. Testing This Architecture

Reducers are easy to test (pure functions).

Providers are tested through behavior:

render(
  <AuthProvider>
    <TestComponent />
  </AuthProvider>,
);

Assert:

  • visible UI

  • transitions after actions

Never test context internals directly.


11. When This Architecture Breaks Down

Context + reducer struggles when:

  • state becomes very large

  • many unrelated domains share the same provider

  • async flows grow complex

  • performance tuning becomes manual

That’s the point where Redux Toolkit earns its place.


12. This Is Redux Without the Ceremony

If this feels familiar, it should.

You’ve recreated:

  • actions

  • reducers

  • dispatch

  • provider

Using React’s built-in tools.

The difference is intentional scope.


13. Summary

You now know how to:

  • combine Context and useReducer cleanly

  • split state and dispatch for performance

  • model app-level state transitions

  • keep reducers pure

  • isolate async logic

  • build a scalable architecture without Redux

Related

Leave a comment

Sign in to leave a comment.

Comments