Skip to main content

Errors

Every failure is either handled or unhandled.

  • Handled means your code kept it: thenKeep(["success", "invalid-body"]) on a request, resolveAlso: ["note-not-found"] on a read. It comes back as a typed value, and the component renders it like any data.
  • Unhandled is everything else: a result nobody listed, a network failure nobody listed, a bug. It goes to the nearest <Boundary>, as a CupleError with a message that is always readable.
const save = useAction(async (values: ContactValues) => {
const response = await fetchCuple(client.updateContact.put, { params: { id }, body: values })
.thenKeep(["success", "invalid-body", "transport-error"]);
// ^? success | invalid-body | transport-error — all typed
if (response.result === "invalid-body") showIssues(response.issues);
if (response.result === "transport-error") showOffline();
return response;
});
// A 403, a 500, a bug: unhandled. The nearest <Boundary> shows error.message.

Handled: list it, get a value​

Any result the endpoint can return can be listed, and so can "transport-error": no answer from the server at all (offline, DNS, a proxy's error page). Listing makes it part of the return type, so TypeScript checks you handle every case you listed, and nothing else reaches your code.

OnWrite
A requestfetchCuple(...).thenKeep(["success", "invalid-body", "transport-error"])
A readuseGet(client.getNote, args, { resolveAlso: ["note-not-found"] })
A read inside combineget(client.getOrder, args, { resolveAlso: ["order-not-found"] })
type TransportErrorResult = { result: "transport-error"; statusCode: number | null; message: string };

Unhandled: the nearest <Boundary>​

<Boundary
fallback={<Skeleton />}
error={(error, retry) => (
<p className="error">
{error.message} <button type="button" onClick={retry}>Retry</button>
</p>
)}
>

error is a CupleError, whatever was thrown:

FieldValue
kind"response": the server answered with a result nobody listed. "transport": no Cuple answer at all. "bug": your code threw.
messageAlways readable. The server's message for "response" when it sent one; your own error's message for "bug" in development. Otherwise errors.fallbackMessage (default "Something went wrong.").
statusCodeThe HTTP status, when there was a response.
resultFor "response": the result the server sent, e.g. "not-your-note".
causeWhat was actually thrown, for logging.

errors.fallbackMessage is set like any config, on the provider, a <Boundary config>, or one request:

<Boundary config={{ errors: { fallbackMessage: "Couldn't load the report." } }} error={(e) => <p>{e.message}</p>}>

Keeping the page: "notify"​

Some unhandled errors shouldn't replace a region: a failed row delete, a dropped connection. errors.onError: "notify" shows them with errors.notify (a toast, a banner) instead, and the page stays:

const config = useMemo<CupleConfig>(
() => ({
errors: {
// How the app shows an error without replacing the page.
notify: (error) => setNotice(error.message),
// An error no code handled: a dropped connection is shown and the page
// stays; anything else goes to the nearest <Boundary>.
onError: (error) => (error.kind === "transport" ? "notify" : "boundary"),
// The message when there's nothing more specific, like a dropped connection.
fallbackMessage: "Something went wrong. Check your connection and try again.",
},
}),
[],
);
  • onError is "boundary" (the default), "notify", null, or a function choosing per CupleError: here a dropped connection is a toast, and anything else goes to the boundary.
  • null means handled: nothing shows it but the component, from the action's status: "failed" and error. A function must return, so a branch that forgot to decide doesn't compile:
onError: (error) => {
if (error.statusCode !== 409) return "boundary";
setConflict(true); // shown by this component
return null;
},
  • Set it on the provider, on a <Boundary config> for a region, or on one action's config.
  • With "notify", the action's status becomes "failed" and error is the CupleError, so the component can show something locally too.

A row action shouldn't take the page with it:

/** One action per row, so each row has its own pending state. */
function NoteRow(props: {
note: { id: number; text: string };
onOpen: (id: number) => void;
}) {
const remove = useAction(
() =>
fetchCuple(client.deleteNote.delete, {
params: { id: props.note.id },
}).thenKeepSuccess(),
// A failed row action shouldn't take the page with it.
{ refresh: refreshes.notes, config: { errors: { onError: "notify" } } },
);

return (
<li className={remove.isPending ? "row pending" : "row"} aria-busy={remove.isPending}>
<button type="button" className="grow" onClick={() => props.onOpen(props.note.id)}>
{props.note.text}
</button>
<BusyButton
busy={remove.isPending}
busyLabel="Deleting"
onClick={() => remove.run()}
>
Delete
</BusyButton>
</li>
);
}

A form handles what it can explain next to the fields, and notifies the rest:

function ContactForm(props: {
title: string;
defaultValues: ContactValues;
submitLabel: string;
busyLabel: string;
save: (values: ContactValues) => Promise<SaveResult>;
onSaved: () => void;
onCancel: () => void;
}) {
const form = useForm<ContactValues>({ defaultValues: props.defaultValues });
const save = useAction(
async (values: ContactValues) => {
const response = await props.save(values);
if (response.result === "invalid-body") showServerIssues(form, response.issues);
else if (response.result === "transport-error")
form.setError("root.server", {
message: "Not saved: the connection dropped. Try again.",
});
else props.onSaved();
return response;
},
{
// Only a successful save changed anything worth refetching.
refresh: { success: refreshes.contacts },
// Anything the form doesn't handle is shown with notify; the form keeps what the user typed.
config: { errors: { onError: "notify" } },
},
);
const { errors } = form.formState;

return (
<section aria-label={props.title}>
<h2>{props.title}</h2>
{/* `run` resolves when the save is done, so react-hook-form's
`isSubmitting` covers it, and it never rejects. */}
<form onSubmit={form.handleSubmit(save.run)} noValidate>
<Field
label="Name"
error={errors.name?.message}
{...form.register("name")}
autoComplete="name"
/>
<Field
label="Email"
error={errors.email?.message}
{...form.register("email")}
type="email"
autoComplete="email"
/>
<fieldset>
<legend>Address</legend>
<Field
label="Street"
error={errors.address?.street?.message}
{...form.register("address.street")}
autoComplete="street-address"
/>
<div className="row">
<Field
label="City"
error={errors.address?.city?.message}
{...form.register("address.city")}
autoComplete="address-level2"
/>
<Field
label="Zip"
error={errors.address?.zip?.message}
{...form.register("address.zip")}
autoComplete="postal-code"
/>
</div>
</fieldset>
{errors.root?.server && <p className="error">{errors.root.server.message}</p>}
<div className="row">
<BusyButton type="submit" busy={save.isPending} busyLabel={props.busyLabel}>
{props.submitLabel}
</BusyButton>
<button type="button" onClick={props.onCancel} disabled={save.isPending}>
Cancel
</button>
</div>
</form>
</section>
);
}

Reads​

A read that fails with something unlisted goes to the nearest <Boundary>. When a region can go without its data, let the boundary notify instead, and show nothing:

<Boundary fallback={<Skeleton width="10rem" />} error="notify">
<Header />
</Boundary>

For a failure that is a normal outcome (a deleted item), list it with resolveAlso and render it. See Reading.

  • NOTE: "notify" needs config.errors.notify. Without it, the error goes to the boundary above, saying what's missing.

Signed out: one place for every 401​

When a session expires, every call behind sign-in starts answering 401. Handle that once, around the signed-in part of the app, not at each call. The auth link names what happened:

const signedIn = builder
.headersSchema(z.looseObject({ authorization: z.string() }))
.middleware(async ({ data }) => {
const user = sessions.get(data.headers.authorization);
if (!user)
return {
next: false as const,
...unauthorized({ result: "session-expired", message: "Please sign in again." }),
};
return { next: true as const, user };
})
.buildLink();

The boundary around the signed-in area checks the status, not the result. Any 401 from inside, a read or an action, signs the user out:

/**
* Everything behind sign-in. A 401 from anything inside, a read or an action,
* ends here and signs the user out. With React Router, render
* `<Navigate to="/login" />` instead of `<SignedOut />`.
*/
function SignedInArea(props: { onSignedOut: () => void; children: ReactNode }) {
return (
<Boundary
fallback={<p>Loading…</p>}
error={(error, retry) =>
error.statusCode === 401 ? (
<SignedOut onSignedOut={props.onSignedOut} />
) : (
<p>
{error.message}{" "}
<button type="button" onClick={retry}>
Retry
</button>
</p>
)
}
>
{props.children}
</Boundary>
);
}

function SignedOut(props: { onSignedOut: () => void }) {
useEffect(props.onSignedOut, []);
return null;
}
  • It branches on statusCode, the category: it handles every 401, whatever each route calls it. Code that handles one specific case branches on result.
  • The reaction is rendered, so it can use your router: with React Router, render <Navigate to="/login" />. The client is created outside React, so useNavigate can't run there.
  • An action whose errors.onError is "notify" shows a 401 as a notification instead. Return "boundary" for it in an onError function to send it here.
  • A call that expects a 401 (a page that shows "sign in to see more") lists it with resolveAlso and renders it, like any other handled result.