4

I have a custom hook to help with async queries to an API. The hook works similar to a common useState statement in that you can set an initial value or leave it undefined. In the case of the built-in useState statement, the type of the state is no longer undefined when an initial value is specified (e.g. the type changes from (TType | undefined) to (TType)). In my custom hook, I have an optional parameter for the initial state, but I need to specify the type of the useState in the hook to be (TData | undefined) in case no initiaState is passed in.

But... when an initialState is passed in, I want the type to be only (TData) without the possibility of it being undefined. Otherwise, I need to put checks in place everywhere I use the hook, even when an initial value is set and it will never be undefined.

Is there a way to set the generic type of the useState inside my hook conditionally (i.e. when (initialState !== undefined) then the type is simply (TData), otherwise it is (TData | undefined)?

useAsyncState.ts

import { useCallback, useEffect, useRef, useState } from "react";

interface PropTypes<TData> {
  /**
   * Promise like async function
   */
  asyncFunc?: () => Promise<TData>;
  /**
   * Initial data
   */
  initialState?: TData,
}

/**
 * A hook to simplify and combine useState/useEffect statements relying on data which is fetched async.
 * 
 * @example
 *   const { execute, loading, data, error } = useAync({
 *     asyncFunc: async () => { return 'data' },
 *     immediate: false,
 *     initialData: 'Hello'
 *   })
 */
const useAsyncState = <TData>({ asyncFunc, initialState }: PropTypes<TData>, dependencies: any[]) => {
  // The type of useState should no longer have undefined as an option when initialState !== undefined
  const [data, setData] = useState<TData | undefined>(initialState);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<any>(null);
  const isMounted = useRef<boolean>(true);

  const execute = useCallback(async () => {
    if (!asyncFunc) {
      return;
    }

    setLoading(true)

    try {
      const result = await asyncFunc();

      if (!isMounted.current) {
        return;
      }

      setLoading(false);
      setData(result);
    } catch (err) {
      if (!isMounted.current) {
        return;
      }

      setLoading(false);
      setError(err);
      console.log(err);
    }
  }, [asyncFunc])

  useEffect(() => {
    isMounted.current = true;
    execute();

    return () => {
      isMounted.current = false
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [...dependencies])

  return {
    execute,
    loading,
    data,
    error,
    setData,
  }
}

export default useAsyncState;

using the hook:

  // Currently, the type of data is: number[] | undefined
  const { data } = useAsyncState({
    asyncFunc: async (): Promise<number[]> => {
      return [1, 2, 3]
    },
    initialState: [],
  }, []);

I assume that the useState hook does something similar to what I want to do here as the type of the state is no longer undefined if an initial value is set with useState.

1 Answers1

2

I originally came up with something that seemed overcomplicated so I asked Titian Cernicova-Dragomir to look it over. He was able to simplify it (as I suspected). It turns out the key was something I'd done quite late in the process of building my original: Using & {initialState?: undefined} in one of two overload signatures to add in undefined to the possible types that the data member of the returned object could have.

Here's the result, with explanatory comments. There's an assumption in there: That you want the setData function not to accept undefined even when there's no initialState (and so TData has undefined in it). But there are instructions for removing that if you want setData to accept TData (even when it includes undefined).

import { useCallback, useEffect, useRef, useState, Dispatch, SetStateAction } from "react";

interface PropTypes<TData> {
    /**
     * Promise like async function
     */
    asyncFunc?: () => Promise<TData>;
    /**
     * Initial data
     */
    initialState?: TData,
}

/**
 * The return type of `useAsyncData`.
 */
type UseAsyncDataHookReturn<TData, TSetData extends TData = TData> = {
    // The `TSetData` type is so that we can allow `undefined` in `TData` but not in `TSetData` so
    // `setData` doesn't allow `undefined`. If you don't need that, just do this:
    // 1. Remove `TSetData` above
    // 2. Use `TData` below where `TSetData` is
    // 3. In the "without" overload in the function signature below, remove the second type argument
    //    in `UseAsyncDataHookReturn` (at the end).
    execute: () => Promise<void>;
    loading: boolean;
    data: TData;
    error: any;
    setData: Dispatch<SetStateAction<TSetData>>;
};

/**
 * An overloaded function type for `useAsyncData`.
 */
interface UseAsyncDataHook {
    // The "without" signature adds `& { initialState?: undefined }` to `ProptTypes<TData>` to make
    // `initialState` optional, and adds `| undefined` to the type of the `data` member of the returned object.
    <TData>({ asyncFunc, initialState }: PropTypes<TData> & { initialState?: undefined }, dependencies: any[]): UseAsyncDataHookReturn<TData | undefined, TData>;
    // The "with" signature just uses `ProptTypes<TData>`, so `initialState` is required and is type `TData`,
    // and the type of `data` in the returned object doesn't have `undefined`, it's just `TData`.
    <TData>({ asyncFunc, initialState }: PropTypes<TData>, dependencies: any[]): UseAsyncDataHookReturn<TData>;
}

/**
 * A hook to simplify and combine useState/useEffect statements relying on data which is fetched async.
 * 
 * @example
 *   const { execute, loading, data, error } = useAync({
 *     asyncFunc: async () => { return 'data' },
 *     immediate: false,
 *     initialData: 'Hello'
 *   })
 */
const useAsyncState: UseAsyncDataHook = <TData,>({ asyncFunc, initialState }: PropTypes<TData>, dependencies: any[])  => {
// Only need this comma in .tsx files −−−−−−−−^
    const [data, setData] = useState(initialState);
    const [loading, setLoading] = useState(true); // *** No need for a type argument in most cases
    const [error, setError] = useState<any>(null); // *** But there is here :-)
    const isMounted = useRef(true);
  
    const execute = useCallback(async () => {
        if (!asyncFunc) {
            return;
        }
      
        setLoading(true);
      
        try {
            const result = await asyncFunc();
        
            if (!isMounted.current) {
                return;
            }
        
            setLoading(false);
            setData(result);
        } catch (err) {
          if (!isMounted.current) {
              return;
          }
      
          setLoading(false);
          setError(err);
          console.log(err);
        }
    }, [asyncFunc]);
  
    useEffect(() => {
        isMounted.current = true;
        execute();
      
        return () => {
            isMounted.current = false;
        };
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [...dependencies]);
  
    return {
        execute,
        loading,
        data,
        error,
        setData,
    };
};

// === Examples

// Array with initial state
const { data, setData } = useAsyncState({
    asyncFunc: async (): Promise<number[]> => {
        return [1, 2, 3]
    },
    initialState: [],
}, []);
console.log(data);
//          ^?
//           type is number[]
console.log(setData);
//          ^?
//          type is Dispatch<SetStateAction<number[]>>

// Array without initial state
const {data: data2, setData: setData2} = useAsyncState({asyncFunc: async () => ([42])}, []);
console.log(data2);
//          ^?
//           type is number[] | undefined
console.log(setData2);
//          ^?
//          type is Dispatch<SetStateAction<number[]>> -- notice that it doesn't allow setting `undefined`

// Object with initial state
const {data: data3, setData: setData3} = useAsyncState({asyncFunc: async () => ({ answer: 42 }), initialState: { answer: 27 }}, []);
console.log(data3);
//          ^?
//           type is {answer: number;}
console.log(setData3);
//          ^?
//          type is Dispatch<SetStateAction<{answer: number;}>>

// Object without initial state
const {data: data4, setData: setData4} = useAsyncState({asyncFunc: async () => ({answer: 42})}, []);
console.log(data4);
//          ^?
//           type is {answer: number} | undefined
console.log(setData4);
//          ^?
//          type is Dispatch<SetStateAction<{answer: number;}>> -- again, notice it doesn't allow `undefined`

// Number with initial state
const {data: data5, setData: setData5} = useAsyncState({asyncFunc: async () => 42, initialState: 27}, []);
console.log(data5);
//          ^?
//           type is number
console.log(setData5);
//          ^?
//          type is Dispatch<SetStateAction<number>>

// Number without initial state
const {data: data6, setData: setData6} = useAsyncState({asyncFunc: async () => 42}, []);
console.log(data6);
//          ^?
//           type is number | undefined
console.log(setData6);
//          ^?
//          type is Dispatch<SetStateAction<number>> -- and again, doesn't allow `undefined`

Playground link

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875