Skip to main content

useFetch

A hook for fetching data from APIs. All requests are routed through a proxy server, which handles CORS automatically so you never have to worry about cross-origin issues.

Loading and error states are handled automatically, keeping your component code focused on rendering data.

Import​

import { useFetch } from "~lib/use-fetch";

API​

const { data } = useFetch<T>(url, options);

Parameters​

NameTypeRequiredDescription
urlstringYesThe URL to fetch data from.
optionsOptions<T>NoRequest configuration. Extends the native RequestInit with an optional retry field.

Options​

The options parameter accepts all standard RequestInit fields (such as method, headers, body, credentials, etc.) plus an optional retry configuration:

type Options<T> = RequestInit & {
retry?: {
condition: (data: T) => boolean;
count?: number;
};
proxyCache?: {
ttl: number;
};
output?: "best-effort" | "force" | "pass-through";
};
FieldTypeRequiredDefaultDescription
retry.condition(data: T) => booleanYesN/AA function that receives the fetched data and returns true to trigger a retry.
retry.countnumberNo3Maximum number of retry attempts.
proxyCache.ttlnumberYesN/AHow long the proxy may reuse a cached response for this URL, in milliseconds. See Caching.
output"best-effort" | "force" | "pass-through"No"best-effort"Controls how the response body is converted before it is returned. See Output format.

Return Value​

PropertyTypeDescription
dataTThe fetched data. Always defined — loading and error states are handled by Suspense and Error Boundaries.

Usage​

GET Request​

By default, the hook fires a GET request:

import { useFetch } from "~lib/use-fetch";

export const Widget = () => {
const { data } = useFetch<{ temperature: number }>(
"https://api.example.com/weather",
);

return <div>{data.temperature}°C</div>;
};

POST Request​

Specify the method and body in the options:

const { data } = useFetch<ResponseType>("https://api.example.com/data", {
method: "POST",
body: JSON.stringify({ key: "value" }),
});

PUT Request​

const { data } = useFetch<ResponseType>("https://api.example.com/data", {
method: "PUT",
body: JSON.stringify({ key: "updated-value" }),
});

Other HTTP Methods​

You can use any HTTP method (DELETE, PATCH, etc.) by specifying it in the options:

const { data } = useFetch<ResponseType>("https://api.example.com/data/123", {
method: "DELETE",
});

body can be a string (such as JSON), URLSearchParams or FormData. Form bodies are encoded the way a browser encodes them, and the matching Content-Type is added unless you set one yourself. Files, Blobs, ArrayBuffers and streams can't be sent, because the proxy carries the body as text, so useFetch throws instead of sending them.

const { data } = useFetch<ResponseType>("https://api.example.com/login", {
method: "POST",
body: new URLSearchParams({ username: "ada", remember: "true" }),
});

Headers​

Pass custom headers via the headers field:

const { data } = useFetch<ResponseType>("https://api.example.com/data", {
headers: {
Authorization: "Bearer my-token",
"X-Custom-Header": "custom-value",
},
});

Headers can also be passed as a Headers object, or as an array of [key, value] pairs:

const { data } = useFetch<ResponseType>("https://api.example.com/data", {
headers: [
["Authorization", "Bearer my-token"],
["Content-Type", "application/json"],
],
});

Credentials​

To include credentials (cookies) with cross-origin requests:

const { data } = useFetch<ResponseType>("https://api.example.com/data", {
credentials: "include",
});

Retry Logic​

The hook supports automatic retries when a condition is met. This is useful for handling transient failures or APIs that return stale data that needs refreshing.

const { data } = useFetch<{ status: string; value: number }>(
"https://api.example.com/data",
{
retry: {
condition: (data) => data.status === "pending",
count: 5,
},
},
);

In this example, the request will be retried up to 5 times if the response status is "pending". Retries use exponential backoff starting at 6 seconds.

If the retry object is not provided, no retries are attempted. If count is omitted, it defaults to 3.

Caching​

Because every request is routed through the proxy server, responses can be cached server-side and reused across all widgets and devices hitting the same URL. Enable this with the proxyCache option:

const { data } = useFetch<{ temperature: number }>(
"https://api.example.com/weather",
{
proxyCache: { ttl: 60_000 },
},
);

With ttl: 60_000 (60 seconds), the first widget to make this request triggers a real upstream call and the proxy stores the response. Any identical request — from any widget, on any device — within the next minute is served the cached response directly by the proxy, without an upstream call.

This is very different from a browser cache:

  • The cache lives on the proxy, so it is shared across all devices and all widgets, not scoped to a single browser.
  • A request only hits the cache when its URL, method, headers and body all match. Changing any of them creates a separate entry.
  • Requests carrying an Authorization, Proxy-Authorization, Cookie or X-Api-Key header are never cached, because their response may belong to one person.
  • The proxy stores whatever the upstream returned, including error responses, until the TTL runs out.
  • The proxy caps the TTL, so a longer value is shortened to that ceiling.

When proxyCache is omitted, no caching is applied and every render fires a fresh upstream request. Use caching for endpoints whose data changes slowly (weather, exchange rates, feed metadata) or whose response is expensive or rate-limited upstream. Avoid it for endpoints that must reflect real-time state.

Separately from the proxy cache, useFetch sends each request once per render: every call that sends the same request shares one response, even when several components ask for it at the same moment. Calls to one URL with a different method, headers, body or output are separate requests and fetch separately.

Keep each request the same​

useFetch suspends until its response arrives, and the retried render looks the response up by the request it builds. The request must therefore come out the same on every render. A value that differs each time, such as a random ID or the current time in the URL, headers or body, sends a new request on every retry and never reads the responses it already has.

useFetch notices when responses to one URL keep going unread and throws an error naming the URL, without its query, instead of requesting it forever. Create such a value once, at module scope:

const DEVICE_ID = Math.random().toString(36).slice(2);

export const Widget = () => {
const { data } = useFetch<Session>("https://api.example.com/sessions", {
method: "POST",
body: JSON.stringify({ deviceId: DEVICE_ID }),
});

return <div>Signed in as {data.name}</div>;
};

A lazy useState initializer is not stable either. React discards the state of a component that suspends before its first commit, so the initializer runs again on every retry.

A reasonable rule of thumb for ttl: pick the longest interval during which a stale response is still acceptable to the widget's viewer.

Output format​

The output option controls how the response body is converted before it is returned. It defaults to "best-effort", which is the behavior described everywhere else in this page.

The proxy JSON-parses whatever it can, so an upstream JSON API arrives here already parsed (an object, array, or scalar). Only a non-JSON upstream (XML, HTML, plain text) arrives as a raw string — and only a string can be parsed as XML. Keep that in mind when reading the table below: "force" and "pass-through" behave differently depending on whether the upstream returned JSON or not.

ValueBehavior
"best-effort"Default. Auto-detects XML by inspecting the response body for a leading <?xml prolog; detected XML is parsed into a plain object (see XML Responses), while JSON and everything else are returned untouched.
"force"Parses the body as XML even when it has no <?xml prolog — but only if the body is a string. Use this for XML APIs that auto-detection would otherwise miss. Has no effect if the upstream already returned valid JSON.
"pass-through"No conversion. Returns exactly what the proxy delivered — a parsed JSON value if the upstream returned JSON, or the raw string otherwise. Type T to match whichever shape your upstream actually returns.
// Force XML parsing for an XML API whose body has no <?xml prolog:
const { data } = useFetch<{ feed: { title: string } }>(
"https://api.example.com/feed",
{ output: "force" },
);

// Opt out of all conversion — useful for a plain-text endpoint, where the proxy has
// nothing to JSON-parse and so delivers the body as a raw string:
const { data } = useFetch<string>("https://api.example.com/raw.txt", {
output: "pass-through",
});

XML Responses​

By default (output: "best-effort"), not every API needs to speak JSON. When a response body begins with an <?xml prolog, the hook automatically parses the XML into a plain object before returning it. Your widget code reads XML APIs exactly the same way it reads JSON ones — no manual parsing required. Detection sniffs the body, not the content-type header, because upstream APIs often mislabel XML (serving it as text/html, for example). If an API returns XML without an <?xml prolog, pass output: "force" to force parsing.

Given this response:

<?xml version="1.0"?>
<note>
<title>Reminder</title>
<body>Water the plants</body>
</note>

You access it as a nested object:

const { data } = useFetch<{ note: { title: string; body: string } }>(
"https://api.example.com/note.xml",
);

return <h1>{data.note.title}</h1>; // "Reminder"

Attributes​

XML attributes are preserved and exposed as keys prefixed with @_. This matters for feeds like RSS and Atom, where meaningful data often lives in attributes rather than element text.

Given this response:

<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>News</title>
</channel>
</rss>

The version attribute becomes @_version:

const { data } = useFetch<{
rss: { "@_version": string; channel: { title: string } };
}>("https://api.example.com/feed.xml");

return (
<p>
{data.rss.channel.title} (RSS {data.rss["@_version"]})
</p>
);

Detection is based on the response body (a leading <?xml prolog), never the content-type header, so JSON and other formats are returned untouched.

Async Variant: useFetchAsync​

An alternative version that does not use Suspense. Instead, data is undefined until the fetch resolves, and error is available if the request fails. Use this when you need more manual control over loading/error states.

import { useFetchAsync } from "~lib/use-fetch";

API​

const { data, error } = useFetchAsync<T>(url, options);

Return Value​

PropertyTypeDescription
dataT | undefinedThe fetched data, or undefined while loading.
errorError | undefinedThe error object if the request failed.

Example​

import { useFetchAsync } from "~lib/use-fetch";

export const Widget = () => {
const { data, error } = useFetchAsync<{ temperature: number }>(
"https://api.example.com/weather",
);

if (error) {
return <div>Error: {error.message}</div>;
}

if (!data) {
return <div>Loading...</div>;
}

return <div>{data.temperature}°C</div>;
};

Full Examples​

Basic Widget with Suspense​

import { useFetch } from "~lib/use-fetch";

type Quote = {
readonly text: string;
readonly author: string;
};

export const Widget = () => {
const { data } = useFetch<Quote[]>("https://api.example.com/quotes");

return (
<div className="h-screen flex flex-col items-center justify-center p-10">
<h1 className="text-4xl text-center">{data[0].text}</h1>
<p className="text-xl">- {data[0].author}</p>
</div>
);
};

POST Request with Typed Response​

import { useFetch } from "~lib/use-fetch";

type SearchResult = {
readonly id: string;
readonly title: string;
readonly score: number;
};

export const Widget = () => {
const { data } = useFetch<{ results: SearchResult[] }>(
"https://api.example.com/search",
{
method: "POST",
body: JSON.stringify({ query: "weather" }),
},
);

return (
<div className="p-4 space-y-2">
{data.results.map((result) => (
<div key={result.id} className="border-b pb-2">
<h2 className="font-bold">{result.title}</h2>
<p>Score: {result.score}</p>
</div>
))}
</div>
);
};

With Retry​

import { useFetch } from "~lib/use-fetch";

type WeatherData = {
readonly temperature: number;
readonly status: string;
};

export const Widget = () => {
const { data } = useFetch<WeatherData>("https://api.example.com/weather", {
retry: {
condition: (data) => data.status === "updating",
count: 3,
},
});

return (
<div className="h-screen flex items-center justify-center">
<h1 className="text-6xl">{data.temperature}°C</h1>
</div>
);
};