Last active
June 12, 2023 18:46
-
-
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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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