How To Modularize This React State Container?
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 })
}
});
Post a Comment for "How To Modularize This React State Container?"