Building a simple data fetching hook in React with typescript

How to create a simple data fetching hook in React with typescript

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 };
};