Skip to content

Data Fetching

Whisq’s resource() wraps a promise and exposes reactive loading(), error(), and data() accessors. The canonical way to render its three-way state is match()prefer match() over chained when() calls for any loading/error/data UI.

See Conditional Rendering — match() for the primitive itself.

import { resource, match, div, p, button, ul, li, each } from "@whisq/core";
const users = resource(() =>
fetch("/api/users").then((r) => r.json()),
);
div(
match(
[() => users.loading(), () => p("Loading…")],
[() => !!users.error(), () => div(
p(() => `Error: ${users.error()!.message}`),
button({ onclick: () => users.refetch() }, "Retry"),
)],
[() => !!users.data(), () => ul(each(() => users.data()!, (u) => li(u.name)))],
),
);

resource() starts fetching immediately, so the three branches cover every state: loading() is true in flight, error() holds the rejection on failure, data() holds the value on success. match() picks the first truthy branch. Bundling the retry button into the error branch is the canonical retry pattern — users only see it when they need it.

No match() fallback is needed here: for an eager resource(), at least one of the three predicates is always true, so a trailing bare function would be unreachable. Reach for a fallback only with resources that can start in a non-loading idle state (for example, lazy resources created but not yet triggered).

const posts = resource(() =>
fetch("/api/posts").then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}),
);
posts.loading(); // boolean — true while fetching
posts.error(); // Error | undefined — rejection reason if failed
posts.data(); // T | undefined — resolved value if successful
posts.refetch(); // re-run the fetcher

Inside the error branch of match(), the non-null assertion on resource.error()! is safe: the branch only runs when the predicate !!resource.error() was true, so the accessor is guaranteed to return an Error.

refetch() re-runs the original fetcher. The canonical place to surface it is the error branch (as in Basic Fetch above), but you can also expose it as a “refresh” affordance:

import { resource, match, div, button, ul, li, p, each } from "@whisq/core";
const todos = resource(() =>
fetch("/api/todos").then((r) => r.json()),
);
div(
button({ onclick: () => todos.refetch() }, "Refresh"),
match(
[() => todos.loading(), () => p("Loading…")],
[() => !!todos.error(), () => p(() => `Error: ${todos.error()!.message}`)],
[() => !!todos.data(), () => ul(each(() => todos.data()!, (t) => li(t.title)))],
),
);

When refetch() fires, the resource briefly returns loading() → true; match() re-evaluates and renders the loading branch until the new response lands.

Refetch when a signal changes by calling refetch() from an effect():

import { signal, effect, resource, match, bind, div, select, option, ul, li, p, each } from "@whisq/core";
const category = signal("all");
const products = resource(() =>
fetch(`/api/products?category=${category.value}`).then((r) => r.json()),
);
effect(() => {
category.value; // track the dependency
products.refetch();
});
div(
select({ ...bind(category) },
option({ value: "all" }, "All"),
option({ value: "books" }, "Books"),
option({ value: "electronics" }, "Electronics"),
),
match(
[() => products.loading(), () => p("Loading…")],
[() => !!products.error(), () => p(() => `Error: ${products.error()!.message}`)],
[() => !!products.data(), () => ul(each(() => products.data()!, (item) => li(item.name)))],
),
);

The select uses bind(category) for two-way binding; when the user picks a new value, category updates, the effect() re-runs, refetch() kicks off a new request, and match() swaps back to the loading branch until it lands.

resource() is for reading. For mutations (POST, PUT, DELETE), use regular signals and an async function:

import { signal, div, button, p, when } from "@whisq/core";
const saving = signal(false);
const error = signal<string | null>(null);
const saveUser = async (payload: { name: string }) => {
saving.value = true;
error.value = null;
try {
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Save failed");
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
} finally {
saving.value = false;
}
};
div(
button({
onclick: () => saveUser({ name: "Alice" }),
disabled: () => saving.value,
}, () => (saving.value ? "Saving…" : "Save")),
when(() => !!error.value, () =>
p({ class: "error" }, () => error.value),
),
);

Mutation flows are usually single-outcome (succeed or show an error), so a plain when() on the error signal is appropriate. Reach for match() only when the UI has three or more distinct states.