简体   繁体   中英

What is the correct to create a interface for action object with react hooks and typescript

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.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM