Skip to content

Instantly share code, notes, and snippets.

@crazy4groovy
Last active June 12, 2023 18:46
Show Gist options
  • Save crazy4groovy/1aec90a580cb927227b3140e56b875eb to your computer and use it in GitHub Desktop.
Save crazy4groovy/1aec90a580cb927227b3140e56b875eb to your computer and use it in GitHub Desktop.
A simple hook to implement state and data handling of a typical fetch HTTP request (ReactJS)
import { useReducer, useRef } from "react";
const initialState = {
isLoading: false,
prevData: undefined,
data: undefined,
error: undefined,
};
const reducer = (state, action) => {
switch (action.type) {
case "FETCH_START":
return {
...state,
isLoading: true,
prevData: state.data ?? state.prevData,
data: undefined,
error: undefined,
};
case "FETCH_SUCCESS":
return {
...state,
isLoading: false,
prevData: undefined,
data: action.payload,
error: undefined,
};
case "FETCH_ERROR":
return {
...state,
isLoading: false,
prevData: undefined,
data: undefined,
error: action.payload,
};
default:
return state;
}
};
const useFetch = ({ baseURL = "", abortCurrent = true }) => {
const [state, dispatch] = useReducer(reducer, initialState);
const timeoutRef = useRef();
const controllerRef = useRef();
const fetchData = async (
URL = "",
options = {},
{ timeoutMs = 2000, convertData }
) => {
if (abortCurrent && controllerRef.current && timeoutRef.current) {
console.log("aborting");
clearTimeout(timeoutRef.current);
controllerRef.current.abort();
}
controllerRef.current = new AbortController();
timeoutRef.current = setTimeout(() => {
controllerRef.current?.abort();
}, timeoutMs);
try {
dispatch({ type: "FETCH_START" });
const response = await fetch(`${baseURL}${URL}`, {
...options,
signal: controllerRef.current.signal,
});
let payload = await response.json();
if (response.ok) {
if (convertData) {
payload = convertData(payload, state.prevData);
}
dispatch({ type: "FETCH_SUCCESS", payload });
} else {
dispatch({
type: "FETCH_ERROR",
payload: new Error("ErrorResponse", { cause: payload }),
});
}
} catch (error) {
dispatch({ type: "FETCH_ERROR", payload: error });
} finally {
clearTimeout(timeoutRef.current);
if (controllerRef) controllerRef.current = undefined;
}
};
return { ...state, fetchData };
};
export default useFetch;
import { renderHook, act } from "@testing-library/react-hooks";
import useFetch, { FetchDataOptions } from "./useFetch";
describe("::useFetch", () => {
beforeEach(() => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: async () => ({ data: "Mocked data" }),
})
);
});
afterEach(() => {
jest.clearAllMocks();
});
it("should fetch data successfully", async () => {
const { result, waitForNextUpdate } = renderHook(() => useFetch());
act(() => {
result.current.fetchData("/data");
});
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBe(undefined);
expect(result.current.error).toBe(undefined);
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toEqual({ data: "Mocked data" });
expect(result.current.error).toBe(undefined);
});
it("should update prevData when fetching new data", async () => {
const { result, waitForNextUpdate } = renderHook(() => useFetch());
act(() => {
result.current.fetchData("/data");
});
expect(result.current.prevData).toBe(undefined);
expect(result.current.data).toBe(undefined);
await waitForNextUpdate();
expect(result.current.prevData).toBe(undefined);
expect(result.current.data).toEqual({ data: "Mocked data" });
act(() => {
result.current.fetchData("/new-data");
});
expect(result.current.prevData).toEqual({ data: "Mocked data" });
expect(result.current.data).toBe(undefined);
await waitForNextUpdate();
expect(result.current.prevData).toBe(undefined);
expect(result.current.data).toEqual({ data: "Mocked data" });
});
it("should handle API fetch error", async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: false,
json: () => Promise.resolve({ message: "Server error" }),
})
);
const { result, waitForNextUpdate } = renderHook(() => useFetch());
act(() => {
result.current.fetchData("/data");
});
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBe(undefined);
expect(result.current.error).toBe(undefined);
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBe(undefined);
expect(result.current.error).toEqual(
new Error("ErrorResponse", { cause: { message: "Server error" } })
);
});
it("should handle network error", async () => {
global.fetch = jest.fn(() => Promise.reject(new Error("Network error")));
const { result, waitForNextUpdate } = renderHook(() => useFetch());
act(() => {
result.current.fetchData("/data");
});
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBe(undefined);
expect(result.current.error).toBe(undefined);
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBe(undefined);
expect(result.current.error).toEqual(new Error("Network error"));
});
it("should convert response using convertData function", async () => {
const convertDataMock = jest.fn((data) => ({ ...data, modified: true }));
const options: FetchDataOptions<any, any> = {
convertData: convertDataMock,
};
const { result, waitForNextUpdate } = renderHook(() => useFetch());
act(() => {
result.current.fetchData("/data", {}, options);
});
await waitForNextUpdate();
expect(convertDataMock).toHaveBeenCalledWith(
{ data: "Mocked data" },
undefined
);
expect(result.current.data).toEqual({
data: "Mocked data",
modified: true,
});
});
});
import { useReducer, useRef } from "react";
interface State<T> {
isLoading: boolean;
prevData: T | void;
data: T | void;
error: Error | void;
}
type Action<T> =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; payload: T }
| { type: "FETCH_ERROR"; payload: Error };
export interface FetchDataOptions<T, R> {
timeoutMs?: number;
convertData?: (r: R, d: T | void) => T;
}
const initialState: State<any> = {
data: undefined,
prevData: undefined,
isLoading: false,
error: undefined,
};
const reducer = <T>(state: State<T>, action: Action<T>): State<T> => {
switch (action.type) {
case "FETCH_START":
return {
...state,
isLoading: true,
prevData: state.data ?? state.prevData,
data: undefined,
error: undefined,
};
case "FETCH_SUCCESS":
return {
...state,
isLoading: false,
prevData: undefined,
data: action.payload,
error: undefined,
};
case "FETCH_ERROR":
return {
...state,
isLoading: false,
prevData: undefined,
data: undefined,
error: action.payload,
};
default:
return state;
}
};
const useFetch = <T, R = T>({ baseURL = "", abortCurrent = true }) => {
const [state, dispatch] = useReducer<React.Reducer<State<T>, Action<T>>>(
reducer,
initialState
);
const timeoutRef = useRef<number | void>();
const controllerRef = useRef<AbortController | void>();
const fetchData = async (
URL = "",
options: RequestInit = {},
{ timeoutMs = 2000, convertData }: FetchDataOptions<T, R>
) => {
if (abortCurrent && controllerRef.current && timeoutRef.current) {
console.log("aborting");
window.clearTimeout(timeoutRef.current);
controllerRef.current.abort();
}
controllerRef.current = new AbortController();
timeoutRef.current = window.setTimeout(() => {
controllerRef.current?.abort();
}, timeoutMs);
try {
dispatch({ type: "FETCH_START" });
const response = await fetch(`${baseURL}${URL}`, {
...options,
signal: controllerRef.current.signal,
});
let payload: T = await response.json();
if (response.ok) {
if (convertData) {
payload = convertData(payload as unknown as R, state.prevData);
}
dispatch({ type: "FETCH_SUCCESS", payload });
} else {
dispatch({
type: "FETCH_ERROR",
payload: new Error("ErrorResponse", { cause: payload }),
});
}
} catch (error: unknown) {
dispatch({ type: "FETCH_ERROR", payload: error as Error });
} finally {
window.clearTimeout(timeoutRef.current!);
if (controllerRef) controllerRef.current = undefined;
}
};
return { ...state, fetchData };
};
export default useFetch;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment