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 preserve before getting to what I want to do with it. Here's the working code. You'll notice it's commented for easy copy and paste to create new ones.
// Set this accordingly.
const hookName = "MyState";
// Default state values. Make sure your values have explicit types set.
const initialState = {
nums: [] as Number[]
};
// Available actions and the data they require. Specify "null" if action requires no data.
type Actions = {
RESET_NUMS: null,
ADD_NUM: Number,
SET_NUMS: Number[]
};
// Action handler methods. There must be a handler for each action listed above.
const actionHandlers: ActionHandlers = {
RESET_NUMS: () => ({ nums: [] }),
ADD_NUM: ({ nums }, num) => {
nums.push(num);
return { nums };
},
SET_NUMS: ({}, nums) => ({ nums })
};
// Rename these exports accordingly.
export const MyStateProvider = Provider;
export const useMyState = useContextState;
/**************************************************
* *
* You do not need to modify anything below this. *
* *
**************************************************/
/* Context Hook Boilerplate */
import React, { useReducer, useContext, ReactNode } from "react";
import _cloneDeep from "lodash/cloneDeep";
const Context = React.createContext<
{
state: State;
dispatch: Dispatch
} | undefined
>(undefined);
function reducer(state: State, action: Action): State {
const stateClone = _cloneDeep(state);
const newState = actionHandlers[action.type](stateClone, action.data as any);
if (!newState) return state;
return { ...stateClone, ...newState };
}
function Provider({children, defaultState}: {children: ReactNode, defaultState?: State}) {
const [state, reducerDispatch] = useReducer(reducer, defaultState ?? initialState);
const dispatch: Dispatch = (type, data) => reducerDispatch({type, data} as Action);
return (
<Context.Provider value={{ state, dispatch }}>
{children}
</Context.Provider>
);
}
function useContextState() {
const context = useContext(Context);
if (context === undefined) {
throw new Error(`use${hookName} must be used within a ${hookName}Provider`);
}
return context;
}
/* TypeScript Boilerplate */
type Dispatch = <A extends Actions, T extends keyof A, D extends A[T]>(type: T, ...data: (D extends null ? [] : [D])) => void
type State = typeof initialState;
type ActionsMap = {
[K in keyof Actions]: Actions[K] extends null ? { type: K, data?: undefined } : { type: K, data: Actions[K] }
};
type Action = ActionsMap[keyof Actions];
type ActionHandlers = {
[K in keyof Actions]: Actions[K] extends null ? (state: State) => Partial<State> | void : (state: State, data: Actions[K]) => Partial<State> | void;
};
What's cool about it is that the state and dispatch functions are very rigidly typed.
- The type of
initialStateis inferred from its structure so when copying this code to make a new state container hook you don't have to write aStatetype and then also a default state object. - The
Actionstype defines actions that are available to be dispatched and the type of each key is the type that is used to enforce the second argument to our dispatch function. - The
actionHandlersobject must have a handler for each action defined onActionsand the handler functions have to conform exactly, receiving the state object as the first argument, and the second argument being a data object that matches the relevant type value onActions. - The
dispatchfunction that consumers of this hook use is almost impossible to misuse. The first argument only accepts valid actions fromActionskeys and the second argument is either not allowed in the case of theActionstype beingnullor is forced to conform to theActionstype specified.
Here's a typescript playground if you wanna mess with it (go to line 65 to tinker with making dispatch calls)
Okay so what you might notice is that the bottom half of the code snippet is boilerplate. Well we use these hooks a lot so we are copying and pasting them into various modules in our project. It works great but it's not very DRY and any change to the boilerplate obviously has to be propagated to every single hook. Tedious.
I want to modularize the boilerplate bit but as I start trying to think about it my mind gets boggled about how to maintain the rigid type structures behind the scenes. Here's a brainstorm of what it might be like to consume such a module:
import StateContainer from "@company/react-state-container";
const myState = new StateContainer("MyState");
myState.defaultState({
nums: [] as number[]
});
myState.actionHandler(
"RESET_NUMS",
() => ({ nums: [] })
);
myState.actionHandler(
"ADD_NUM",
({ nums }, num: number) => ({ nums: [...nums, num] })
);
myState.actionHandler(
"SET_NUMS",
({}, nums: number[]) => ({ nums })
);
const {
Provider: MyStateProvider,
useStateContainer: useMyState
} = myState;
export { MyStateProvider, useMyState };
This is just the first thing that came to my head as far as an API goes. Maybe you can think of something better. While consuming the API makes sense to me, how to write this module is where I'm tripping over myself. For one example: How can I infer the type of the default state like I do in the original code? I can write the defaultState class method using generics but then how do I propagate that generic type into the rest of the class outside of that one method? Is that even possible?
Another question I had pretty quickly. Should I expect the user to provide user-defined types, like for Actions? Should the consumer have to pass in TypeScript stuff at all or is there a way I can just have them pass only the imperative code and then I can layer on some type magic for those who are taking advantage of TypeScript?
The more I start trying to modularize that code the more I feel like I'm just taking the wrong approach altogether or maybe what I want to do is a fool's errand in the first place.
PS - I know I know, Redux. Sadly I don't get to decide.