Building a simple data fetching hook in React with typescript
How to create a simple data fetching hook in React with typescript
Photo by VD Photography on Unsplash
There was an ask at one point to come up with a generic data fetching hook. The idea was to standardise how we would handle the various loading, success & error states and also, ensure only the latest data becomes available to the UI by ignoring stale resolved promises.
The internal state of the useFetch
hook looked like this:
export enum Status {
IDLE = 'IDLE',
PENDING = 'PENDING',
SUCCESS = 'SUCCESS',
ERROR = 'ERROR',
}
type State<T> = {
status: Status;
data?: T;
errors?: boolean;
};
const [state, setState] = useState<State<T>>(initialState);
So far so good. I implemented a side effect to ensure data is fetched when dependencies changed.
Here, I started making setState
calls by passing in a setter function with spreads all over the place. While this worked, it would be better to use a tool designed specifically for handling related state changes atomically.
Enter, useReducer
!
I quickly defined the shape of my action and possible action types:
enum ActionTypes {
PENDING = 'PENDING',
SUCCESS = 'SUCCESS',
ERROR = 'ERROR',
}
type Action = {
type: ActionTypes;
payload?: any;
};
Then came my actual reducer function:
function reducer<T>(state: State<T>, action: Action): State<T> {
switch (action.type) {
case ActionTypes.PENDING: {
return {
...state,
status: Status.PENDING,
};
}
case ActionTypes.SUCCESS: {
return {
...state,
status: Status.SUCCESS,
data: action.payload,
};
}
case ActionTypes.ERROR: {
return {
...state,
status: Status.ERROR,
errors: action.payload,
};
}
}
}
Then I consumed this inside my hook like this:
const [state, dispatch] = useReducer(
reducer,
initialState
);
At this point, typescript started complaining as it was unable to derive the type of state
. And rightfully so as our reducer function has no clue about T
.
This led to a deepdive into useReducer
typings. As it turns out, this is how the type looks like for usage without an initializer:
function useReducer<R extends Reducer<any, any>>(
reducer: R,
initialState: ReducerState<R>,
initializer?: undefined
): [ReducerState<R>, Dispatch<ReducerAction<R>>];
Going deeper, Reducer
looks like this:
type Reducer<S, A> = (prevState: S, action: A) => S;
This is good news as we can now tell useReducer
types what shape our data is going to be.
Here, S
is type of state & A
is the type of action. Both of which we have defined already during implementing our reducer
function.
This is how the type will look like:
React.Reducer<State<T>, Action>
This I consumed in my implementation like this:
const [state, dispatch] = useReducer<React.Reducer<State<T>, Action>>(
reducer,
initialState
);
Now, my state
is getting it's type correctly and once again, all is well with the world :)
Bringing it all together:
import React, { useEffect, useMemo, useReducer } from 'react';
type useFetchArgs<T> = {
api: () => Promise<T>;
deps: any[];
};
export enum Status {
IDLE = 'IDLE',
PENDING = 'PENDING',
SUCCESS = 'SUCCESS',
ERROR = 'ERROR',
}
type State<T> = {
status: Status;
data?: T;
errors?: boolean;
};
interface FetchResponseIDLE<T> extends State<T> {
isLoading: boolean;
hasError: boolean;
}
type FetchResponsePending<T> = {
status: Status;
data?: T;
errors?: boolean;
isLoading: boolean;
hasError: boolean;
};
type FetchResponseSuccess<T> = {
status: Status;
data: T;
errors?: boolean;
isLoading: boolean;
hasError: boolean;
};
type FetchResponseError<T> = {
status: Status;
data?: T;
errors: any;
isLoading: boolean;
hasError: boolean;
};
const initialState = {
status: Status.IDLE,
};
enum ActionTypes {
PENDING = 'PENDING',
SUCCESS = 'SUCCESS',
ERROR = 'ERROR',
}
type Action = {
type: ActionTypes;
payload?: any;
};
function reducer<T>(state: State<T>, action: Action): State<T> {
switch (action.type) {
case ActionTypes.PENDING: {
return {
...state,
status: Status.PENDING,
};
}
case ActionTypes.SUCCESS: {
return {
...state,
status: Status.SUCCESS,
data: action.payload,
};
}
case ActionTypes.ERROR: {
return {
...state,
status: Status.ERROR,
errors: action.payload,
};
}
}
}
export const useFetch = <T>({
api,
deps,
}: useFetchArgs<T>):
| FetchResponseIDLE<T>
| FetchResponsePending<T>
| FetchResponseSuccess<T>
| FetchResponseError<T> => {
const [state, dispatch] = useReducer<React.Reducer<State<T>, Action>>(
reducer,
initialState
);
const { status, data, errors } = state;
const { isLoading, hasError } = useMemo(() => {
return {
isLoading: status === Status.PENDING,
hasError: status === Status.ERROR,
};
}, [status]);
useEffect(() => {
let isCancelled = false;
const fetchData = async () => {
dispatch({
type: ActionTypes.PENDING,
});
try {
const res = await api();
if (!isCancelled) {
dispatch({
type: ActionTypes.SUCCESS,
payload: res,
});
}
} catch (e) {
if (!isCancelled) {
dispatch({
type: ActionTypes.ERROR,
payload: e,
});
}
}
};
fetchData();
return () => {
isCancelled = true;
};
}, deps);
if (hasError) {
return { status, errors, isLoading, hasError };
}
if (isLoading) {
return { status, isLoading, hasError };
}
return { status, data, errors, isLoading, hasError };
};