Skip to content

Error Boundaries

When something inside a Whisq component tree throws — a render-time error, a signal-driven recomputation, an event handler, an effect, or even a child component’s setup — you usually want to show fallback UI instead of crashing the whole page. Whisq ships a first-class primitive for exactly this: errorBoundary().

Wrap any subtree in errorBoundary(fallback, child):

import { errorBoundary, div, p, button } from "@whisq/core";
errorBoundary(
(error, retry) =>
div({ class: "error-boundary" },
p(`Something went wrong: ${error.message}`),
button({ onclick: retry }, "Try again"),
),
() => RiskyComponent({}),
);
  • fallback(error, retry) => Node. Called when the wrapped subtree throws. Receives the caught error and a retry function that re-runs the child.
  • child() => Node. The protected subtree. The thunk lets errorBoundary re-invoke it on retry.

Why use the primitive instead of a hand-rolled try/catch in a component? A try/catch inside a component setup function only sees errors thrown synchronously while that setup runs. Anything that happens later — a child component’s setup, a re-render after a signal changes, an event handler, an async callback — runs outside that try block and is not caught. errorBoundary() is integrated with Whisq’s render machinery and is scoped to the wrapped subtree, so it sees errors thrown anywhere in that subtree’s rendering. Use the primitive — don’t roll your own.

For asynchronous failures (network, timers, deferred promises), see Async data below — resource() is the right tool, not errorBoundary().

Wrap individual sections rather than the entire app — if one widget fails, the rest of the page keeps working:

import { component, errorBoundary, div, h1, p } from "@whisq/core";
const Dashboard = component(() =>
div(
h1("Dashboard"),
errorBoundary(
(err) => p("Stats unavailable"),
() => StatsWidget({}),
),
errorBoundary(
(err) => p("Chart failed to load"),
() => ChartWidget({}),
),
errorBoundary(
(err) => p("Table unavailable"),
() => TableWidget({}),
),
)
);

If the chart throws, the stats and table still render normally.

Silently swallowing errors hides bugs. Always log them — either in the fallback callback or via a small reporter helper:

import { errorBoundary, div, p, button } from "@whisq/core";
const reportError = (error: Error, where: string) => {
console.error(`[${where}]`, error);
// fetch("/api/errors", { method: "POST", body: JSON.stringify({ where, message: error.message }) });
};
errorBoundary(
(error, retry) => {
reportError(error, "ChartWidget");
return div({ class: "error" },
p("This section failed to load."),
button({ onclick: retry }, "Retry"),
);
},
() => ChartWidget({}),
);

For network and other async failures, you don’t need a separate error boundary at all. resource() already exposes loading and error state as signals:

import { resource, div, p, when, button } from "@whisq/core";
const data = resource(() =>
fetch("/api/data").then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
}),
);
div(
when(() => data.loading(), () => p("Loading…")),
when(() => !!data.error(), () =>
div(
p(() => `Error: ${data.error()!.message}`),
button({ onclick: () => data.refetch() }, "Retry"),
),
),
when(() => !!data.data(), () => p(() => data.data()!)),
);

Reach for errorBoundary() around a subtree that uses resource() only when you also want to catch unexpected render-time errors in that subtree — resource() handles the documented async failure path, errorBoundary() is the safety net for everything else.

  1. Wrap at section boundaries, not every component. Logical units (widgets, panels, routes) are the right granularity.
  2. Always provide actionable fallback UI — explain what failed and offer a retry button when it makes sense.
  3. Always log or reportconsole.error at minimum; a real reporting service in production.
  4. Use resource() for async failures; reserve errorBoundary() for unexpected throws.