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