The bullet proof useReducer - Typescript (2/2)

pffigueiredo

Pedro Figueiredo

Posted on April 6, 2022

The bullet proof useReducer - Typescript (2/2)

This blog post takes by granted that you are aware of useReducer logic and the basics regarding TypeScript.

Bringing TypeScript to the table

In the previous blog post we went in full detail on how to leverage React's useReducer, but there is still something missing for it to be fully bullet proof - TypeScript.

Why does it help

When applying TypeScript into useReducer you will not only feel a sense of security, but also, feel a lot more confident to touch code and modify any logic related with the states inside the reducer.

Pros of using TypeScript:

  • Type safety
  • Type completion
  • Makes sure all states are handled
  • Makes sure an Action sends the correct data

Cons of using TypeScript

  • Brings a little more complexity
  • Makes it harder to hack in a new state quickly

From where I see it, the pros overcome the cons by a lot and as such, I strongly advise you to add some sort of typing to your code.

Typing fetchReducer

In the last post, we finished with this plain JS reducer:

JS reducer print

Typing Actions

To start, we need to type the different possible actions, so that we have type completion depending on what we are dispatching.

1- Define Action



type Action<DataT, ErrorT> ={}


Enter fullscreen mode Exit fullscreen mode

Action object needs to take in two generics, one for the Data type and one of the Error type.

2- Define FETCH



{ type: "FETCH" }


Enter fullscreen mode Exit fullscreen mode

For FETCH we really only need to define the type's property type, which is a string literal and nothing more.

3- Define RESOLVE



{ type: "RESOLVE", data: DataT }


Enter fullscreen mode Exit fullscreen mode

When we dispatch RESOLVE it means that the fetch was successful and we already have the data - this action ensures that when we do dispatch({type: "RESOLVE"}) there is a type error for not passing the data.

4- Define REJECT



{ type: "REJECT", error: ErrorT }


Enter fullscreen mode Exit fullscreen mode

REJECT acts pretty much as the success action, meaning, that when we dispatch this action, TypeScript will make us pass an error along.

5- Union of actions



type Action<DataT, ErrorT> =
  | { type: "FETCH" }
  | { type: "RESOLVE"; data: DataT }
  | { type: "REJECT"; error: ErrorT };


Enter fullscreen mode Exit fullscreen mode

Our action final type is just an union of all our defined actions, meaning, it can take any of those forms.

Typing States

In order to add more strictness to our reducer, each one of the states should have their own type definition.

All of these states must have the same properties, status, data and error, but for each one of the states, these properties will have their own type definitions, depending on the situation.

1- Typing iddle



type IddleState<DataT> = {
  status: "idle";
  data: Nullable<DataT>;
  error: null;
};


Enter fullscreen mode Exit fullscreen mode

The iddle state takes the DataT generic, so that it allows the reducer to start with initial data. Everything else is pretty standard for all the other reducer states.

2- Typing loading



type LoadingState<DataT, ErrorT> = {
  status: "loading";
  data: Nullable<DataT>;
  error: Nullable<ErrorT>;
};


Enter fullscreen mode Exit fullscreen mode

The loading state needs to take both DataT and ErrorT generics, as it depends too much on the implementation details if we wanna show or not errors while fetching new data.

3- Typing success



type SucessState<DataT> = {
  status: "success";
  data: DataT;
  error: null;
};


Enter fullscreen mode Exit fullscreen mode

The success state only needs the DataT generic and we can already define the error property can be nothing but null, this way, we protect our selves to set errors while in the success state (impossible state)!

4- Typing failure



type FailureState<ErrorT> = {
  status: "failure";
  data: null;
  error: ErrorT;
};


Enter fullscreen mode Exit fullscreen mode

The failure state behaves pretty much like the success one, but in the opposite direction, by setting the error needs a value and that the data must be of the null type.

5- Union of States



type State<DataT, ErrorT> =
  | IddleState<DataT>
  | LoadingState<DataT, ErrorT>
  | SucessState<DataT>
  | FailureState<ErrorT>;


Enter fullscreen mode Exit fullscreen mode

Just like our Action type, State is also just an union of all the possible states that our reducer can return

Typing reducer function

Now that we have all our states and actions properly typed, it's just a matter of adding those to fetchReducer function it self.

1- Adding generics to the function



function fetchReducer<DataT, ErrorT = string>(
    currentState,
    action
  ){
...
}


Enter fullscreen mode Exit fullscreen mode

We defined ErrorT as an optional generic by defining it as string by default.

2-Typing the arguments and the return type



function fetchReducer<DataT, ErrorT = string>(
    currentState: State<DataT, ErrorT>,
    action: Action<DataT, ErrorT>
  ): State<DataT, ErrorT> {
...
}


Enter fullscreen mode Exit fullscreen mode

We just need to take our existing Action and State defined types, and add them to the respective parameters.

For the return type, it was also just a matter of defining that this reducer, can only return any of the states that is inside the State union type.

Typing useFetchReducer

Although the reducer function is already properly typed, we still need to add typing to our custom useReducer hook.

1- Passing the generics to the useFetchReducer



// added the generics here
function useFetchReducer<DataT, ErrorT = string>(
  initialData
){

// removed them from the reducer
  function fetchReducer(
    state: State<DataT, ErrorT>,
    event: Event<DataT, ErrorT>
  )
}


Enter fullscreen mode Exit fullscreen mode

By providing generics to the useFetchReducer hook, we don't need to have them on the reducer's signature anymore, as we can use the ones provided above and keep things consistent.

2-Typing initialData argument



function useFetchReducer<DataT, ErrorT = string>(
  initialData: Nullable<DataT> = null
): [State<DataT, ErrorT>, React.Dispatch<Action<DataT, ErrorT>>] {...}


Enter fullscreen mode Exit fullscreen mode

As far as initalData goes, if you wanted to pass in anything, it would have to be the same type that you defined your generic previously.

3-Typing initialState constant



  const initialState: IddleState<DataT> = {
    status: "idle",
    data: initialData,
    error: null,
  };


Enter fullscreen mode Exit fullscreen mode

We should use the IddleState type for the initialState constant, this way, if we decide to change it, TypeScript will make sure they are in sync.

The final type



import { useReducer } from "react";

type Nullable<T> = T | null | undefined;

type IddleState<DataT> = {
  status: "idle";
  data: Nullable<DataT>;
  error: null;
};

type LoadingState<DataT, ErrorT> = {
  status: "loading";
  data: Nullable<DataT>;
  error: Nullable<ErrorT>;
};

type SucessState<DataT> = {
  status: "success";
  data: DataT;
  error: null;
};

type FailureState<ErrorT> = {
  status: "failure";
  data: null;
  error: ErrorT;
};

type State<DataT, ErrorT> =
  | IddleState<DataT>
  | LoadingState<DataT, ErrorT>
  | SucessState<DataT>
  | FailureState<ErrorT>;

type Event<DataT, ErrorT> =
  | { type: "FETCH" }
  | { type: "RESOLVE"; data: DataT }
  | { type: "REJECT"; error: ErrorT };

function useFetchReducer<DataT, ErrorT = string>(
  initialData: Nullable<DataT> = null
) {
  const initialState: IddleState<DataT> = {
    status: "idle",
    data: initialData,
    error: null,
  };

  function fetchReducer(
    state: State<DataT, ErrorT>,
    event: Event<DataT, ErrorT>
  ): State<DataT, ErrorT> {
    switch (event.type) {
      case "FETCH":
        return {
          ...state,
          status: "loading",
        };
      case "RESOLVE":
        return {
          status: "success",
          data: event.data,
          error: null
        };
      case "REJECT":
        return {
          status: "failure",
          data: null,
          error: event.error,
        };
      default:
        return state;
    }
  }

  return useReducer(fetchReducer, initialState);
}


Enter fullscreen mode Exit fullscreen mode

After all this typing, we should be pretty safe when trying to access any reducer's state or even when dispatching actions.

Dispatching Actions

dispatching actions gif

As you can perceive from this GIF, TypeScript doesn't allow us to pass in incorrect Actions into the dispatcher function

Accessing reducer's state
accessing state gif

If you look closely, you will notice that TypeScript can infer what's the data and error types by the current state.

This feature it's called Discriminating Unions and it works by having a Discriminator property in each one of the union types, that can help TypeScript narrow down which is the current state - in our case it is the status, which is unique for each of union types.

Conclusion

By using TypeScript in conjunction with the useReducer hook, you will be able to create robust React UI's, as well as iterate on top of them with much more confidence.

Summarizing everything we discussed above, these are the steps you should take to create a properly typed useReducer hook:

1- Type each action individually and create a super type, which is the union of all of them;
2 - Type each state individually and create a super type, which is the union of all of them;
3 - Add the necessary generic types to the useReducer and reducer function.

And that's it, you just improved your Developer Experience by a lot, and not only that, but by doing all these typing, you ended up creating a thin testing layer that will probably spare you from many coming bugs.


Make sure to follow me on twitter if you want read about TypeScript best practices or just web development in general!

💖 💪 🙅 🚩
pffigueiredo
Pedro Figueiredo

Posted on April 6, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related