Search code examples
reactjstypescripttypescript-genericsuse-reducer

How to setup a generic interface for the state when using React useReducer with typescript


I face this problem when I try to setup an interface to the state when trying to use useReducer.I use it in a custom hook for fetching data from the server. The data object in response from the server may contain different properties. the useFetcher.ts

const initialState = {
  data: null,
  loading: false,
  error: null,
};
type LIST_RESPONSE = {
  page: number;
  totalRows: number;
  pageSize: number;
  totalPages: number;
  list: [];
};
interface State {
  data: LIST_RESPONSE| null;
  loading: boolean;
  error: string | null;
}
const reducer = (state: State, action: ACTIONTYPES) => {
  switch (action.type) {
    case ACTIONS.FETCHING: {
      return {
        ...state,
        loading: true,
      };
    }
    case ACTIONS.FETCH_SUCCESS: {
      return {
        ...state,
        loading: false,
        data: action.data,
      };
    }
    case ACTIONS.ERROR: {
      return {
        ...state,
        loading: false,
        error: action.error,
      };
    }
  }
};

const useFetcher = (url: string): State => {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { data, loading, error } = state;

  useEffect(() => {
    dispatch({ type: ACTIONS.FETCHING });
    const fetchData = async () => {
      try {
        const response = await httpService.get(url);
        dispatch({ type: ACTIONS.FETCH_SUCCESS, data: response.data });
        return;
      } catch (error: unknown) {
        const { message } = error as Error;
        dispatch({ type: ACTIONS.ERROR, error: message });
      }
    };

    fetchData();
  }, [url]);
  return { data, loading, error };
};

export default useFetcher;

Component A call useFetcher:

 const { data, loading, error } = useFetcher('productList');

this works fine, it returns the data object that fits the LIST_RESPONSE type. but if I want want to use the useFetcher function in Component B, it is expected to return another data type, let's say like this:

type CATEGORTY_RESPONSE= {
  categories: [];
};

How can I make the data type in the interface State generic? for example, I can define the data type in the State interface when I am calling the useFecher hook like this: component A:

const { data, loading, error } = useFetcher<LIST_RESPONSE>('productList');

component B:

const { data, loading, error } = useFetcher<CATEGORTY_RESPONSE>('categories');

I tried

interface State {
  data: LIST_RESPONSE|CATEGORTY_RESPONSE| null;
  loading: boolean;
  error: string | null;
}

or

const useFetcher = <Type extends State>(url: string): Type =>{...}

none of them works. Any help?thanks


Solution

  • Creating a Generic Hook

    In order to use a generic, you will need to apply that generic at multiple points in the chain:

    • The useFetcher hook needs to know what state type it returns.
    • The useReducer hook needs to know the type of the reducer.
    • The reducer needs to know the type of its state and actions.
    • The State needs to know the type for its data property.
    • The success action needs to know the type for its data property.

    Here's what that looks like:

    import {useEffect, useReducer, Reducer} from "react";
    
    enum ACTIONS {
        FETCHING = 'FETCHING',
        FETCH_SUCCESS = 'FETCH_SUCCESS',
        ERROR = 'ERROR'
    }
    
    type ACTIONTYPES<DataType> = {
        type: ACTIONS.FETCHING
    } | {
        type: ACTIONS.ERROR;
        error: string
    } | {
        type: ACTIONS.FETCH_SUCCESS;
        data: DataType;
    }
    
    const initialState = {
      data: null,
      loading: false,
      error: null,
    };
    
    interface State<DataType> {
      data: DataType | null;
      loading: boolean;
      error: string | null;
    }
    
    const reducer = <DataType extends any>(state: State<DataType>, action: ACTIONTYPES<DataType>) => {
      switch (action.type) {
        case ACTIONS.FETCHING: {
          return {
            ...state,
            loading: true,
          };
        }
        case ACTIONS.FETCH_SUCCESS: {
          return {
            ...state,
            loading: false,
            data: action.data,
          };
        }
        case ACTIONS.ERROR: {
          return {
            ...state,
            loading: false,
            error: action.error,
          };
        }
        default: {
            return state;
        }
      }
    };
    
    const useFetcher = <DataType extends any>(url: string): State<DataType> => {
      const [state, dispatch] = useReducer<Reducer<State<DataType>, ACTIONTYPES<DataType>>>(reducer, initialState);
      const { data, loading, error } = state;
    
      useEffect(() => {
        dispatch({ type: ACTIONS.FETCHING });
        const fetchData = async () => {
          try {
            const response = await httpService.get(url);
            dispatch({ type: ACTIONS.FETCH_SUCCESS, data: response.data });
            return;
          } catch (error: unknown) {
            const { message } = error as Error;
            dispatch({ type: ACTIONS.ERROR, error: message });
          }
        };
    
        fetchData();
      }, [url]);
      return { data, loading, error };
    };
    
    export default useFetcher;
    

    Instead of using Type extends State, I am using the same generic value throughout, which refers to the type of data that we get from the server. This is the only type that changes.

    The part which is annoying is setting the generic on useReducer because the generic here needs to be the type of the reducer rather than the type of the state. The react Reducer type is defined as Reducer<S, A> where S is the state type and A is the action type. So we wind up with Reducer<State<DataType>, ACTIONTYPES<DataType>> which is the type of your reducer function, but limited to the specific DataType.


    Using a Generic Hook

    The useFetcher hook is fully generic and the API response data could be anything. We are not limited to just LIST_RESPONSE and CATEGORTY_RESPONSE.

    For example, you could do this:

    const { data, loading, error } = useFetcher<string>('someUrl');
    

    and the type of data will be inferred as string | null.


    Defining a List Response

    There's a problem with your current LIST_RESPONSE (and CATEGORTY_RESPONSE) type. You have declared the type of the list property to be [] which is an empty array. You need to say that it is an array whose elements are a specific type.

    If the list item type is always the same then we can define a type for it and use list: ListItem[];. If the type varies based on the URL then we can use another generic to describe it.

    type LIST_RESPONSE<ItemType> = {
      page: number;
      totalRows: number;
      pageSize: number;
      totalPages: number;
      list: ItemType[];
    };
    

    We can then use your useFetcher hook for any type of list. Like a list of products:

    interface Product {
        price: number;
    }
    
    const { data, loading, error } = useFetcher<LIST_RESPONSE<Product>>('productList');
    
    if ( data ) {
    
        // has type Product
        const product = data.list[0];
    
        // has type number
        const price = product.price;
    }
    

    If that's a bit confusing then you can think of it in steps.

    A ProductListResponse is a specific type of LIST_RESPONSE where the list items are of the type Product:

    type ProductListResponse = LIST_RESPONSE<Product>
    

    Our 'productList' endpoint returns data which has the type ProductListResponse:

    const { data, loading, error } = useFetcher<ProductListResponse>('productList');
    

    Typescript Playground Link