Skip to content

Instantly share code, notes, and snippets.

@colbywhite
Last active July 15, 2025 22:21
Show Gist options
  • Save colbywhite/70a2ef7a60820d4a64cf2e45d68727b6 to your computer and use it in GitHub Desktop.
Save colbywhite/70a2ef7a60820d4a64cf2e45d68727b6 to your computer and use it in GitHub Desktop.
type ClientOptions = {
baseUrl: string;
getToken?: () => Promise<string> | string,
defaults?: RequestInit,
}
/**
* A simple http client factory that wraps `fetch` so there are defaults.
* The goal is to resist the need to reach for a client lib
* while allowing a project's conventions/defaults to be baked into the factory.
**/
export function httpClientFactory({baseUrl, getToken, defaults = {}}: ClientOptions) {
const fetchWithDefaults = async <ReturnType>(path: string, _init?: RequestInit) => {
const init = _init || {};
const url = `${baseUrl}${path}`;
const authHeaders = getToken
? {Authorization: `Bearer ${await getToken()}`} // insert whatever auth strategy your project is using
: {};
const headers: HeadersInit = {
'Content-Type': 'application/json',
...defaults.headers,
...authHeaders,
...init.headers, // TODO: handle case where it's type `Headers`or `[string, string][]`
};
const requestInit: RequestInit = {
credentials: 'same-origin',
...defaults,
...init,
headers,
};
// insert whatever serialization your project is using.
if (requestInit.body
&& typeof requestInit.body === 'object'
&& !(requestInit.body instanceof FormData)
&& requestInit.headers['Content-Type'] === 'application/json') {
requestInit.body = JSON.stringify(init.body);
}
const res = await fetch(url, requestInit);
const text = await res.text();
const body = text
? res.headers['Content-Type'] === 'application/json'
? safeJsonParse<ReturnType>(text)
: text
: undefined
// insert whatever error handling your project is using.
if (!res.ok || res.status !== 304) {
throw captureHttpResponseError(res, url, requestInit, body);
}
return body;
};
return {
get: <T>(path: string, init: RequestInit) =>
fetchWithDefaults<T>(path, {...init, method: 'GET'}),
post: <T>(path: string, init: RequestInit) =>
fetchWithDefaults<T>(path, {...init, method: 'POST'}),
put: <T>(path: string, init: RequestInit) =>
fetchWithDefaults<T>(path, {...init, method: 'PUT'}),
patch: <T>(path: string, init: RequestInit) =>
fetchWithDefaults<T>(path, {...init, method: 'PATCH'}),
delete: <T>(path: string, init: RequestInit) =>
fetchWithDefaults<T>(path, {...init, method: 'DELETE'}),
fetch: fetchWithDefaults,
};
}
const safeJsonParse = <T>(text: string) => {
try {
return JSON.parse(text) as T;
} catch {
return text;
}
};
import { useMemo } from 'react';
import { httpClientFactory, type ClientOptions } from './httpClientFactory';
export function useHttpClient({ baseUrl, getToken, defaults }: ClientOptions) {
const client = useMemo(() => {
return httpClientFactory({ baseUrl, getToken, defaults });
}, [baseUrl, getToken, JSON.stringify(defaults)]); // Is there a better way than `stringify`?
return client;
}
export function useApiClient() {
return useHttpClient(
'/api',
getSessionToken,
defaults: {
credentials: 'include'
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment