createState function using React Context and ImmerJS

8/3/2020 • ☕️ 2 min read

In this post I am going to show a function for global state management in React applications using React Context and ImmerJS. It is heavily inspired by this post.

Enough words, please read the final code :)

import React, { useContext, useEffect, useReducer } from "react";
import { useRouter } from "next/router";
import produce from "immer";
import mapValues from "lodash/mapValues";

type Children = { children?: any };
type ProviderFC = React.FC<Children>;

type MutationFn<T> = (state: Partial<T>) => void;
type Mutations<T> = {
  [name: string]: (state: T, ...args: any[]) => void;
};

type Update<T, M extends Mutations<T>> = { setState: MutationFn<T> } & M;
type UseStateResult<T, M extends Mutations<T>> = [T, MutationFn<T>, M];
type UseStateFn<T, M extends Mutations<T>> = () => UseStateResult<T, M>;

export default function createState<T, M extends Mutations<T>>(
  {
    initialState,
    loadState = () => Promise.resolve(initialState),
  }: {
    initialState: T;
    loadState?: () => Promise<T>;
  },
  mutations?: M
): [ProviderFC, UseStateFn<T, M>] {
  const StateContext = React.createContext<T>(initialState);
  const UpdateContext = React.createContext<Update<T, M>>(null as any);

  function Provider({ children }) {
    const router = useRouter();
    const [state, update] = useReducer(produce, initialState);
    const mutate = update as any;

    const setState = (newState: Partial<T>) => {
      mutate((target: any) => {
        Object.assign(target, newState);
      });
    };

    const actions = mapValues(mutations, (fn) => (...params: any[]) => {
      mutate((target: any) => {
        fn(target, ...params);
      });
    });

    const loadAsync = async () => {
      const newState = await loadState();
      mutate((target: any) => {
        Object.assign(target, newState);
      });
    };

    // TODO avoid this effect if loadAsync is not defined
    useEffect(() => {
      loadAsync();
    }, [router.pathname]);

    return (
      <UpdateContext.Provider value={{ setState, ...actions } as any}>
        <StateContext.Provider value={state as any}>
          {children}
        </StateContext.Provider>
      </UpdateContext.Provider>
    );
  }

  function useState(): UseStateResult<T, M> {
    const { setState, ...mutations } = useContext(UpdateContext);
    return [useContext(StateContext), setState, mutations as any];
  }

  return [Provider, useState];
}

The createState function can be used as follow:

// auth state
const initialState = {
  id: "",
  name: "",
  email: "",
  is_owner: false,
  is_admin: false,
  is_authenticated: !!getApiToken(),
};

type State = typeof initialState;

const [UserProvider, useUser] = createState({
  initialState,
  loadState: fetchAuthState,
});

export { UserProvider, useUser };

async function fetchAuthState(): Promise<State> {
  if (!getApiToken()) {
    return initialState;
  }
  try {
    const resp = await api.me();
    return {
      ...initialState,
      ...resp.data,
      is_authenticated: true,
    };
  } catch (err) {
    return initialState;
  }
}

// example login page
function LoginPage() {
   const [_, setUser] = useUser();
   const handleSubmit = async () => {
       const resp = await api.login(form);
       setUser({
            ...omit(resp.data, ['token', "token_expired_at"]),
          is_authenticated: true,
       });
   };
   return <div> Login Form </div>
}

The example is truncated a bit to save your time.

This function allows specifying custom typed mutations as an object with functions accepting current state to be mutated. This can be optionally used to decompose a reducer function with big switch into smaller mutation functions. And you don’t need to define actions, just define functions with parameters.

Enjoy! EOF 😄