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 aCupleErrorwith 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.
| On | Write |
|---|---|
| A request | fetchCuple(...).thenKeep(["success", "invalid-body", "transport-error"]) |
| A read | useGet(client.getNote, args, { resolveAlso: ["note-not-found"] }) |
A read inside combine | get(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:
| Field | Value |
|---|---|
kind | "response": the server answered with a result nobody listed. "transport": no Cuple answer at all. "bug": your code threw. |
message | Always 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."). |
statusCode | The HTTP status, when there was a response. |
result | For "response": the result the server sent, e.g. "not-your-note". |
cause | What 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.",
},
}),
[],
);
onErroris"boundary"(the default),"notify",null, or a function choosing perCupleError: here a dropped connection is a toast, and anything else goes to the boundary.nullmeans handled: nothing shows it but the component, from the action'sstatus: "failed"anderror. 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'sconfig. - With
"notify", the action'sstatusbecomes"failed"anderroris theCupleError, 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"needsconfig.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 every401, whatever each route calls it. Code that handles one specific case branches onresult. - The reaction is rendered, so it can use your router: with React Router, render
<Navigate to="/login" />. The client is created outside React, souseNavigatecan't run there. - An action whose
errors.onErroris"notify"shows a401as a notification instead. Return"boundary"for it in anonErrorfunction to send it here. - A call that expects a
401(a page that shows "sign in to see more") lists it withresolveAlsoand renders it, like any other handled result.