Home > Mobile >  TypeScript Dynamic Types based on React state
TypeScript Dynamic Types based on React state

Time:10-02

I'm building a generic useFetch hook, this hook will return an object that depends on the success or failure of the data fething. I want this hook to return the appropriate dynamic type.

This is the type I created:


type Status = "loading" | "success" | "error";

interface FetchBase<TData = unknown> {
  status: Status;
  isLoading: boolean;
  isError: boolean;
  isSuccess: boolean;

  data: TData | undefined;
  error: undefined | string;
}

interface FetchLoading<TData = unknown> extends FetchBase<TData> {
  status: "loading";
  isLoading: true;
  isError: false;
  isSuccess: false;

  data: undefined;
  error: undefined;
}

interface FetchError<TData = unknown> extends FetchBase<TData> {
  status: "error";
  isLoading: false;
  isError: true;
  isSuccess: false;

  data: undefined;
  error: string;
}

interface FetchSuccess<TData = unknown> extends FetchBase<TData> {
  status: "success";
  isLoading: false;
  isError: false;
  isSuccess: true;

  data: TData;
  error: undefined;
}

type FetchResult<TData> =
  | FetchLoading<TData>
  | FetchError<TData>
  | FetchSuccess<TData>;

And this is the hook I mean (useFetch):

function useFetch<TData>(
  fetchFn: () => Promise<AxiosResponse<TData>>
): FetchResult<TData> {
  const [status, setStatus] = useState<Status>("loading");
  const [error, setError] = useState<string>();
  const [data, setData] = useState<TData>();

  useEffect(() => {
    const initFetch = async () => {
      try {
        const res = await fetchFn();
        setData(res.data);
        setStatus("success");
      } catch (error: any) {
        setError(error.message);
        setStatus("error");
      }
    };

    initFetch();

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // error
  return {
    status,
    isLoading: status === "loading",
    isError: status === "error",
    isSuccess: status === "success",
    data,
    error,
  };
}

The problem is TypeScript complains because of the object I return from the useFetch hook, this is the error I get:

Type '{ status: Status; isLoading: boolean; isError: boolean; isSuccess: boolean; data: TData | undefined; error: string | undefined; }' is not assignable to type 'FetchResult<TData>'.
  Type '{ status: Status; isLoading: boolean; isError: boolean; isSuccess: boolean; data: TData | undefined; error: string | undefined; }' is not assignable to type 'FetchSuccess<TData>'.
    Types of property 'status' are incompatible.
      Type 'Status' is not assignable to type '"success"'.
        Type '"loading"' is not assignable to type '"success"'.ts(2322)

I do this because I don't want to do state data checking, it should be if loading = false then the data or error is there. It works fine when I try in React component:

function TestComponent() {
  const { isLoading, isError, error, data } = useFetch<{ name: string }>(() =>
    axios.get("/user")
  );

  if (isLoading) return <p>Loading...</p>;

  if (isError) return <p>{error}</p>;

  // i don't need to do something like below (if(data) ....):
  // if(data) return data.name;

  return <p>{data.name}</p>;
}

CodePudding user response:

THe most easier way to fix it, is to overload your function:

import React, { useState, useEffect } from 'react'
import { AxiosResponse } from 'axios'

type Status = "loading" | "success" | "error";

interface FetchBase<TData = unknown> {
    status: Status;
    isLoading: boolean;
    isError: boolean;
    isSuccess: boolean;

    data: TData | undefined;
    error: undefined | string;
}

interface FetchLoading<TData = unknown> extends FetchBase<TData> {
    status: "loading";
    isLoading: true;
    isError: false;
    isSuccess: false;

    data: undefined;
    error: undefined;
}

interface FetchError<TData = unknown> extends FetchBase<TData> {
    status: "error";
    isLoading: false;
    isError: true;
    isSuccess: false;

    data: undefined;
    error: string;
}

interface FetchSuccess<TData = unknown> extends FetchBase<TData> {
    status: "success";
    isLoading: false;
    isError: false;
    isSuccess: true;

    data: TData;
    error: undefined;
}

type FetchResult<TData> =
    | FetchLoading<TData>
    | FetchError<TData>
    | FetchSuccess<TData>;

function useFetch<TData>(
    fetchFn: () => Promise<AxiosResponse<TData>>
): FetchResult<TData>
function useFetch<TData>(
    fetchFn: () => Promise<AxiosResponse<TData>>
) {
    const [status, setStatus] = useState<Status>("loading");
    const [error, setError] = useState<string>();
    const [data, setData] = useState<TData>();

    useEffect(() => {
        const initFetch = async () => {
            try {
                const res = await fetchFn();
                setData(res.data);
                setStatus("success");
            } catch (error: any) {
                setError(error.message);
                setStatus("error");
            }
        };

        initFetch();

    }, []);

    return {
        status,
        isLoading: status === "loading",
        isError: status === "error",
        isSuccess: status === "success",
        data,
        error,
    };
}

Playground

But, if you want to make it safer you need to create a custom typeguard to make sure that return value satisfies one of three allowed states:

import React, { useState, useEffect } from 'react'
import { AxiosResponse } from 'axios'

type Status = "loading" | "success" | "error";

interface FetchBase<TData = unknown> {
    status: Status;
    isLoading: boolean;
    isError: boolean;
    isSuccess: boolean;

    data: TData | undefined;
    error: undefined | string;
}

interface FetchLoading<TData = unknown> extends FetchBase<TData> {
    status: "loading";
    isLoading: true;
    isError: false;
    isSuccess: false;

    data: undefined;
    error: undefined;
}

interface FetchError<TData = unknown> extends FetchBase<TData> {
    status: "error";
    isLoading: false;
    isError: true;
    isSuccess: false;

    data: undefined;
    error: string;
}

interface FetchSuccess<TData = unknown> extends FetchBase<TData> {
    status: "success";
    isLoading: false;
    isError: false;
    isSuccess: true;

    data: TData;
    error: undefined;
}

type FetchResult<TData> =
    | FetchLoading<TData>
    | FetchError<TData>
    | FetchSuccess<TData>;


const isLoading = <T,>(response: FetchBase<T>): response is FetchLoading<T> => {
    const {
        status,
        isLoading,
        isError,
        isSuccess,
        data,
        error
    } = response;
    return (
        status === 'loading'
        && isLoading
        && !isError
        && !isSuccess
        && data === undefined
        && error === undefined
    )
}

const isError = <T,>(response: FetchBase<T>): response is FetchError<T> => {
    const {
        status,
        isLoading,
        isError,
        isSuccess,
        data,
        error
    } = response;
    return (status === 'error'
        && !isLoading
        && isError
        && !isSuccess
        && data === undefined
        && typeof error === 'string'
    )
}

const isSuccess = <T,>(response: FetchBase<T>): response is FetchSuccess<T> => {
    const {
        status,
        isLoading,
        isError,
        isSuccess,
        data,
        error
    } = response;
    return (status === 'success'
        && !isLoading
        && !isError
        && isSuccess
        && data !== undefined
        && data !== null
        && error === undefined
    )
}

function useFetch<TData>(
    fetchFn: () => Promise<AxiosResponse<TData>>
): FetchResult<TData> | null {
    const [status, setStatus] = useState<Status>("loading");
    const [error, setError] = useState<string>();
    const [data, setData] = useState<TData>();

    useEffect(() => {
        const initFetch = async () => {
            try {
                const res = await fetchFn();
                setData(res.data);
                setStatus("success");
            } catch (error: any) {
                setError(error.message);
                setStatus("error");
            }
        };

        initFetch();

    }, []);
    const result = {
        status,
        isLoading: status === "loading",
        isError: status === "error",
        isSuccess: status === "success",
        data,
        error,
    };

    if (isLoading(result) || isError(result) || isSuccess(result)) {
        return result
    }

    return null
}

Playground

There is still no 100% guarantee that result will be allowed state, this is why I think it worth returning null when for some reason all these props

        isLoading,
        isError,
        isSuccess,

will be false or true

  • Related