Created
March 12, 2025 09:54
-
-
Save Ribeiro-Tiago/51aaf2eee06cfb49c75e9e01335c53e9 to your computer and use it in GitHub Desktop.
fetch composable for vue
This file contains hidden or 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
// heavily inspired by nuxt's useFetch | |
// functionalities: | |
// - delayed execution | |
// - result prop picking | |
// - custom transformer | |
// - state related callbacks (state change, success, error) | |
// - auto retry | |
// - request caching | |
// - auto fetch based on watched props | |
// - request dedup | |
// - request abort | |
// todo: error handling is still very coupled to the project where this was created. need to fix this | |
import { ref, watch, type Ref } from "vue"; | |
import { useRouter } from "vue-router/auto"; | |
import axios, { RequestError } from "https://gist.github.com/Ribeiro-Tiago/e5fe2dfaf9aa7e78347afa252b8e90b2"; | |
type Obj = Record<string, any>; | |
type TransformFunction<D, R> = (data: D) => R; | |
type Method = "GET" | "DELETE" | "HEAD" | "OPTIONS" | "POST" | "PATCH"; | |
interface RequestOptions<RequestData = any, TransformResult = RequestData> { | |
method: Method; | |
retry: boolean; | |
numRetries: number; | |
dedupe: boolean; | |
immediate: boolean; | |
body?: Obj; | |
pick?: string[]; | |
redirectToOnForbidden?: string; | |
cacheRequests?: boolean; | |
transform?: TransformFunction<RequestData, TransformResult>; | |
onSuccess?: (data: RequestData | TransformResult) => void; | |
onError?: (error: RequestData | TransformResult) => void; | |
onPendingChange?: (pending: boolean, state: RequestState) => void; | |
} | |
export enum RequestState { | |
PENDING = "pending", | |
SUCCESS = "success", | |
ERROR = "error", | |
ABORTED = "aborted", | |
} | |
const cachedRequests: Obj = {}; | |
const initOptionDefaults = (options: Partial<RequestOptions>): RequestOptions => { | |
options.method = options.method || "GET"; | |
if (options.retry !== false) { | |
options.numRetries = options.numRetries ?? 3; | |
options.retry = options.retry ?? true; | |
} else { | |
options.numRetries = 3; | |
options.retry = true; | |
} | |
options.dedupe = options.dedupe ?? true; | |
options.immediate = options.immediate ?? true; | |
options.redirectToOnForbidden = options.redirectToOnForbidden ?? ""; | |
options.cacheRequests = options.cacheRequests ?? true; | |
return options as RequestOptions; | |
}; | |
const removePropsFromObj = (obj: Obj, pick: string[]): Obj => { | |
return Object.entries(obj).reduce<Obj>((result, [key, value]) => { | |
if (pick.includes(key)) { | |
result[key] = value; | |
} | |
return result; | |
}, {}); | |
}; | |
const mapData = <T, D>(data: T, transform?: TransformFunction<T, D>, pick?: string[]): T | D => { | |
if (!pick && !transform) { | |
return data; | |
} | |
if (pick) { | |
return Array.isArray(data) | |
? (data.map((item) => removePropsFromObj(item, pick)) as D) | |
: (removePropsFromObj(data as any, pick) as D); | |
} | |
return Array.isArray(data) ? (data.map((item) => transform!(item)) as D) : transform!(data); | |
}; | |
let lastRequestHash: number = -1; | |
// make a hash out of the request using DJB2 algorithm | |
const hashRequest = (url: string, body?: Obj) => { | |
const str = `${url}${body ? JSON.stringify(body) : ""}`; | |
let hash = 5381; // A large prime number | |
for (let i = 0; i < str.length; i++) { | |
hash = (hash * 33) ^ str.charCodeAt(i); // Multiply hash by 33 and XOR with char code | |
} | |
return hash >>> 0; // Ensure the hash is an unsigned 32-bit integer | |
}; | |
const getCachedRequest = (url: string, body?: Obj) => { | |
const requestHash = hashRequest(url, body); | |
if (!lastRequestHash || lastRequestHash !== requestHash) { | |
lastRequestHash = requestHash; | |
return null; | |
} | |
return cachedRequests[requestHash]; | |
}; | |
export const useFetch = async <T = any, D = any>( | |
url: string, | |
options: Partial<RequestOptions<T, D>> = {}, | |
watched: Ref[] | null = null, | |
) => { | |
const $router = useRouter(); | |
// set defaults | |
const data = ref<T | D>(); | |
const error = ref<any>(); | |
const pending = ref(false); | |
const state = ref<RequestState>(RequestState.SUCCESS); | |
const opts: RequestOptions = initOptionDefaults(options); | |
let controller = new AbortController(); | |
const makeRequest = async (retries = 0) => { | |
try { | |
const result = await axios.request<any, T>({ | |
url, | |
method: opts.method, | |
data: opts.body, | |
signal: controller.signal, | |
}); | |
// todo: update typing so result is the actual data instead of result.data | |
data.value = mapData<T, D>(result, opts.transform, opts.pick); | |
if (options.cacheRequests) { | |
// cache request result so when we need it next we already have | |
// the data locally and don't need to make the request again | |
cachedRequests[lastRequestHash] = data.value; | |
} | |
error.value = null; | |
_updatePending(false, RequestState.SUCCESS); | |
opts.onSuccess?.(data.value); | |
} catch (err) { | |
if (err instanceof RequestError) { | |
// if something went wrong, we check if should retry, | |
// if we don't wanna retry or reached all attempts, just error message | |
if (err.status === 500) { | |
if (opts.retry && retries < opts.numRetries!) { | |
setTimeout(() => makeRequest(++retries), 1000 * retries); | |
return; | |
} | |
reportUnexpectedError(err); | |
} | |
// user has no permissions, do nothing or redirect to | |
// another page based on the opts.redirectToOnForbidden | |
else if (err.status === 403) { | |
reportError(err.reason); | |
if (opts.redirectToOnForbidden) { | |
return $router.replace(opts.redirectToOnForbidden); | |
} | |
} | |
// user has no access, redirect to no access | |
if (err.status === 401) { | |
return $router.replace("no-access"); | |
} | |
} else { | |
reportUnexpectedError(err); | |
} | |
error.value = err; | |
_updatePending(false, RequestState.ERROR); | |
opts.onError?.(err); | |
} | |
}; | |
const execute = async (force?: boolean) => { | |
// if we're already making a request and we want to prevent | |
// duplicates, stop this execution and let the previous do it's thing | |
// if we don't want to prevent duplicates, carry on | |
if (pending.value && opts.dedupe) { | |
return; | |
} | |
// if request is cached, and we don't want | |
// to force fetch new data use cache | |
if (opts.cacheRequests && !force && opts.method === "GET") { | |
const cached = getCachedRequest(url, opts.body); | |
if (cached) { | |
data.value = cached; | |
_updatePending(false, RequestState.SUCCESS); | |
return; | |
} | |
} | |
_updatePending(true, RequestState.PENDING); | |
await makeRequest(); | |
}; | |
const abort = () => { | |
_updatePending(false, RequestState.ABORTED); | |
controller.abort(); | |
controller = new AbortController(); | |
}; | |
const _updatePending = (val: boolean, newState: RequestState) => { | |
pending.value = val; | |
state.value = newState; | |
opts.onPendingChange?.(val, newState); | |
}; | |
if (watched) { | |
watched.forEach((prop) => watch(prop, () => execute(), { deep: true })); | |
} | |
if (opts.immediate) { | |
await execute(); | |
} | |
return { data, error, state, pending, abort, execute, refresh: () => execute(true) }; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment