The bullet proof useReducer - Typescript (2/2)
Pedro Figueiredo
Posted on April 6, 2022
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:
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> ={}
Action object needs to take in two generics, one for the Data type and one of the Error type.
2- Define FETCH
{ type: "FETCH" }
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 }
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 }
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 };
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;
};
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>;
};
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;
};
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;
};
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>;
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
){
...
}
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> {
...
}
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>
)
}
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>>] {...}
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,
};
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);
}
After all this typing, we should be pretty safe when trying to access any reducer's state or even when dispatching actions.
Dispatching Actions
As you can perceive from this GIF, TypeScript doesn't allow us to pass in incorrect Actions into the dispatcher function
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!
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
March 11, 2023