Skip to content

Instantly share code, notes, and snippets.

@Ribeiro-Tiago
Created March 12, 2025 09:54
Show Gist options
  • Save Ribeiro-Tiago/51aaf2eee06cfb49c75e9e01335c53e9 to your computer and use it in GitHub Desktop.
Save Ribeiro-Tiago/51aaf2eee06cfb49c75e9e01335c53e9 to your computer and use it in GitHub Desktop.
fetch composable for vue
// 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