I am working with react hooks and typescript. I used useReducer()
for global state. The action of the reducer function contains two properties name
and data
. name
means the name of event or change and data
will be particular data required for that particular name.
There are four value for name till now. If name "setUserData"
then data
should IUserData
(interface). If name is setDialog
then data
should DialogNames
(type containing two strings). And if its something else then data is not required.
//different names of dialog.
export type DialogNames = "RegisterFormDialog" | "LoginFormDialog" | "";
//type for name property in action object
type GlobalStateActionNames =
| "startLoading"
| "stopLoading"
| "setUserData"
| "setDialog";
//interface for main global state object.
export interface IGlobalState {
loading: boolean;
userData: IUserData;
dialog: DialogNames;
}
interface IUserData {
loggedIn: boolean;
name: string;
}
//The initial global state
export const initialGlobalState: IGlobalState = {
loading: false,
userData: { loggedIn: false, name: "" },
dialog: ""
};
//The reducer function which is used in `App` component.
export const GlobalStateReducer = (
state: IGlobalState,
{ name, data }: IGlobalStateAction
): IGlobalState => {
switch (name) {
case "startLoading":
return { ...state, loading: true };
case "stopLoading":
return { ...state, loading: false };
case "setUserData":
return { ...state, userData: { ...state.userData, ...data } };
case "setDialog":
return { ...state, dialog: data };
default:
return state;
}
};
//The interface object which is passed from GlobalContext.Provider as "value"
export interface GlobalContextState {
globalState: IGlobalState;
dispatchGlobal: React.Dispatch<IGlobalStateAction<GlobalStateActionNames>>;
}
//intital state which is passed to `createContext`
export const initialGlobalContextState: GlobalContextState = {
globalState: initialGlobalState,
dispatchGlobal: function(){}
};
//The main function which set the type of data based on the generic type passed.
export interface IGlobalStateAction<
N extends GlobalStateActionNames = GlobalStateActionNames
> {
data?: N extends "setUserData"
? IUserData
: N extends "setDialog"
? DialogNames
: any;
name: N;
}
export const GlobalContext = React.createContext(initialGlobalContextState);
My <App>
component looks like.
const App: React.SFC = () => {
const [globalState, dispatch] = React.useReducer(
GlobalStateReducer,
initialGlobalState
);
return (
<GlobalContext.Provider
value={{
globalState,
dispatchGlobal: dispatch
}}
>
<Child></Child>
</GlobalContext.Provider>
);
};
The above approach is fine. I have to use it like below in <Child>
dispatchGlobal({
name: "setUserData",
data: { loggedIn: false }
} as IGlobalStateAction<"setUserData">);
The problem is above approach is that it makes code a little longer. And second problem is I have to import IGlobalStateAction
for not reason where ever I have to use dispatchGlobal
Is there a way that I could only tell name
and data
is automatically assigned to correct type or any other better way. Kindly guide to to the correct path.
Using useReducer
with typescript is a bit tricky, because as you've mentioned the parameters for reducer vary depending on which action you take.
I came up with a pattern where you use classes to implement your actions. This allows you to pass typesafe parameters into the class' constructor and still use the class' superclass as the type for the reducer's parameter. Sounds probably more complicated than it is, here's an example:
interface Action<StateType> {
execute(state: StateType): StateType;
}
// Your global state
type MyState = {
loading: boolean;
message: string;
};
class SetLoadingAction implements Action<MyState> {
// this is where you define the parameter types of the action
constructor(private loading: boolean) {}
execute(currentState: MyState) {
return {
...currentState,
// this is how you use the parameters
loading: this.loading
};
}
}
Because the state update logic is now encapsulated into the class' execute
method, the reducer is now only this small:
const myStateReducer = (state: MyState, action: Action<MyState>) => action.execute(state);
A component using this reducer might look like this:
const Test: FunctionComponent = () => {
const [state, dispatch] = useReducer(myStateReducer, initialState);
return (
<div>
Loading: {state.loading}
<button onClick={() => dispatch(new SetLoadingAction(true))}>Set Loading to true</button>
<button onClick={() => dispatch(new SetLoadingAction(false))}>Set Loading to false</button>
</div>
);
}
If you use this pattern your actions encapsulate the state update logic in their execute method, which (in my opinion) scales better, as you don't get a reducer with a huge switch-case. You are also completely typesafe as the input parameter's types are defined by the action's constructor and the reducer can simply take any implementation of the Action
interface.
The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.