Skip to content Skip to sidebar Skip to footer

How To Modularize This React State Container?

So at work we have this awesome state container hook we built to use in our React application and associated packages. First a little background on this hook and what I'd like to p

Solution 1:

PS - I know I know, Redux. Sadly I don't get to decide.

Yes, you're basically re-creating Redux here. More specifically, you're trying to re-create the createSlice functionality of Redux Toolkit. You want to define a mapping of action names to what the action does, and then have the reducer get created automatically. So we can use that prior art to get an idea of how this might work.

Your current brainstorm involves calling functions on the StateContainer object after it has been created. Those functions need to change the types of the StateContainer. This is doable, but it's easier to create the object in one go with all of the information up front.

Let's think about what information needs to be provided and what information needs to be returned.

We need a name, an initialState, and a bunch of actions:

typeConfig<S,AH>= {
  name:string;initialState:S;actionHandlers:AH;
}

The type of the state and the type of the actions are generics where S represents State and AH represents ActionHandlers. I'm using letters to make it clear what's a generic and what's an actual type.

We want to put some sort of constraint on the actions. It should be an object whose keys are strings (the action names) and whose values are functions. Those functions take the state and a payload (which will have a different type for each action) and return a new state. Actually your code says that we return Partial<State> | void. I'm not sure what that void accomplishes? But we get this:

typeGenericActionHandler<S, P> = (state: S, payload: P) =>Partial<S> | void;

typeConfig<S, AHextendsRecord<string, GenericActionHandler<S, any>>> = {
...

Our utility is going to take that Config and return a Provider and a hook with properties state and dispatch. It's the dispatch that requires us to do the fancy TypeScript inference in order to get the correct types for the type and data arguments. FYI, having those as two separate arguments does make is slightly harder to ensure that you've got a matching pair.

The typing is similar to the "TypeScript Boilerplate" that you had before. The main difference is that we are working backwards from ActionHandlers to Actions. The nested ternary here handles the situation where there is no second argument.

typeActions<AH> = {
  [K in keyof AH]: AH[K] extendsGenericActionHandler<any, infer P>
    ? (unknown extends P ? never : P )
    : never;
};

typeTypePayloadPair<AH> = {
  [K in keyof AH]: Actions<AH>[K] extendsnull | undefined
    ? [K]
    : [K, Actions<AH>[K]];
}[keyof AH];

typeDispatch<AH> = (...args: TypePayloadPair<AH>) =>void;

So now, finally, we know the return type of the StateContainer object. Given a state type S and an action handlers object AH, the container type is:

typeStateContainer<S,AH>= {
  Provider:React.FC<{defaultState?:S}>;useContextState:()=> {
    state:S;dispatch:Dispatch<AH>;
  }
}

We'll use that as the return type for the factory function, which I am calling createStateContainer. The argument type is the Config that we wrote earlier.

The conversion of type and data to {type, data} as Action is not really necessary because the React useReducer hook doesn't make any requirements about the action type. You can avoid all as assertions within your function if you pass along the pair of arguments from dispatch as-is.

exportdefaultfunction createStateContainer<
    S,
    AHextendsRecord<string, GenericActionHandler<S, unknown>>
>({
    name,
    initialState,
    actionHandlers,
}: Config<S, AH>): StateContainer<S, AH> {

  constContext = React.createContext<
      {
          state: S;
          dispatch: Dispatch<AH>
      } | undefined
  >(undefined);

  functionreducer(state: S, [type, payload]: TypePayloadPair<AH>): S {
      const stateClone = _cloneDeep(state);
      const newState = actionHandlers[type](stateClone, payload);
      if (!newState) return state;
      return { ...stateClone, ...newState };
  }

  functionProvider({children, defaultState}: {children?: ReactNode, defaultState?: S}) {
      const [state, reducerDispatch] = useReducer(reducer, defaultState ?? initialState);
      constdispatch: Dispatch<AH> = (...args) =>reducerDispatch(args);
      
      return (
          <Context.Providervalue={{state, dispatch }}>
              {children}
          </Context.Provider>
      );
  }

  functionuseContextState() {
      const context = useContext(Context);
      if (context === undefined) {
          thrownewError(`use${name} must be used within a ${name}Provider`);
      }
      return context;
  }

  return {
    Provider,
    useContextState
  }
}

Creating an instance has gotten much, much simpler:

import createStateContainer from"./StateContainer";

exportconst {
  Provider: MyStateProvider,
  useContextState: useMyState
} = createStateContainer({
  name: "MyState",
  initialState: {
    nums: [] asnumber[]
  },
  actionHandlers: {
    RESET_NUMS: () => ({ nums: [] }),
    ADD_NUM: ({ nums }, num: number) => {
      nums.push(num);
      return { nums };
    },
    SET_NUMS: ({}, nums: number[]) => ({ nums })
  }
});

Complete Code and Demo

Post a Comment for "How To Modularize This React State Container?"