← Back to all posts

Reusable React Context Abstraction with Type Safety

October 23, 2021

When working on large-scale React applications, state management quickly becomes a critical concern. You might reach for libraries like Redux or Zustand, but sometimes all you need is a lightweight, type-safe Context-based solution that integrates seamlessly with React’s built-in APIs. In this post I’ll walk through an abstraction I built—createAppState—that gives you:

  • Strong typing for state and actions
  • Automatic timestamping on every state update
  • A simple hook, consumer, and HOC API
  • Zero dependencies beyond React

🎯 Motivation

In one of my recent projects, I needed to share complex state across many components without coupling them too tightly. I wanted:

  1. Type safety. Avoid careless mistakes when dispatching updates.
  2. Automatic metadata. Track when state last changed for cache-busting or debugging.
  3. Minimal boilerplate. No need to write separate action types, reducers, or switch statements.
  4. Flexibility. Support both synchronous and async actions, HOC wrapping, and classic Consumer usage if needed.

React’s Context API has everything we need—so let’s build a small factory to generate all the plumbing for us.


📦 The Abstraction: createAppState

Here’s the heart of the solution. We define a generic AppState interface, an AppAction type, and then the createAppState function that returns four utilities:

export interface AppState {
  timestamp: number;
  [key: string]: any;
}

export type AppAction<ST extends AppState> = (state: ST) => ST;

export interface AppContext<ST extends AppState> {
  state: ST;
  dispatch: React.Dispatch<AppAction<ST>>;
}
  • AppState always includes a timestamp plus whatever you need.
  • AppAction is simply a function that takes the previous state and returns the next one.
  • AppContext bundles the current state plus a dispatch function.

Now the factory:

export function createAppState<ST extends AppState>(initialValue: ST) {
  const context = createContext<AppContext<ST>>({
    state: initialValue,
    dispatch: () => null,
  });
  const Consumer = context.Consumer;

  const Provider: FunctionComponent = ({ children }) => {
    const [state, setState] = useState<ST>(initialValue);

    const dispatch: Dispatch<AppAction<ST>> = (action) => {
      setState(prev => ({
        ...action(prev),
        timestamp: Date.now(),
      }));
    };

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

  const useAppState = () => {
    const ctx = useContext(context);
    if (!ctx) {
      throw new Error(
        `App State must be used within a Provider`
      );
    }
    return ctx;
  };

  const withAppState = <P extends object>(
    Component: ComponentType<P>
  ): FunctionComponent<P> => props => (
    <Provider>
      <Component {...props} />
    </Provider>
  );

  return { Consumer, Provider, useAppState, withAppState };
}

What You Get

  • Provider: Sets up state and dispatch, automatically stamping timestamp.
  • Consumer: Legacy render-prop API if you need fine-grained control.
  • useAppState: Hook to read state and call dispatch.
  • withAppState: HOC to wrap any component and inject state context.

🚀 Usage Example

Imagine we have a hierarchy of teams. Define your state type:

interface Team {
  name: string;
  children: Team[];
}

interface TeamsAppState extends AppState {
  rootTeam: Team;
}

const initialTeams: TeamsAppState = {
  timestamp: Date.now(),
  rootTeam: { id: 'root', name: 'Root', depth: 0, children: [] }
};

const {
  Provider: TeamsProvider,
  useAppState: useTeamsContext,
  Consumer: TeamsConsumer
} = createAppState<TeamsAppState>(initialTeams);

In a Component

function TeamList() {
  const { state, dispatch } = useTeamsContext();
  return (
    <>
      <h2>Root team: {state.rootTeam.name}</h2>
      <button onClick={() => dispatch(addTeam('New Team', 0))}>
        Add Team
      </button>
    </>
  );
}

Defining Actions

const addTeam = (name: string, parentId: number): AppAction<TeamsAppState> => state => {
  // your update logic here...
  return { 
    ...state, 
    teams: /* new tree */,
  };
};

Async Actions

async function createTeamOnServer(name: string) {
  // pretend API call
  return { id: '111', name, depth: 1, children: [] };
}

function addTeamAsync(name: string) {
  createTeamOnServer(name).then(team => {
    dispatch(addTeam(team.name, 0));
  });
}

Or use the Consumer render-prop if you prefer:

function SomeButton() {
  return (
    <TeamsConsumer>
      {({ dispatch }) => (
        <button onClick={() => dispatch(addTeam('Foo', 0))}>
          Dispatch Foo
        </button>
      )}
    </TeamsConsumer>
  );
}

👍 Benefits & Considerations

  1. Type-safe: Generics ensure your AppState and AppAction align.
  2. Minimal boilerplate: No action type enums, switch statements, or reducers to write.
  3. Automatic timestamps: Every state update records Date.now(), great for debugging or cache invalidation.
  4. Flexible APIs: Hook, Consumer, or HOC—pick your style.
  5. Lightweight: Zero external deps; built on top of React alone.

Potential enhancements:

  • Swap useState for useReducer if you need more control over batching or middleware.
  • Memoize context value to avoid unnecessary re-renders.
  • Add a simple middleware layer for logging or undo/redo.

🏁 Conclusion

This tiny abstraction packs all the essentials for most Context-based state needs: type safety, timestamping, and a clean API. It lives purely in userland, has no runtime cost beyond React, and scales from simple use cases to relatively complex hierarchies. Feel free to fork it, tweak it, and let me know how it helps streamline your React projects!