Actions
The server has these routes:
export const routes = {
getTodos: builder.get(async () => success({ todos })),
createTodo: builder
.bodySchema(z.strictObject({ text: z.string().min(1, "Write something first.") }))
.post(async ({ data }) => {
if (todos.some((todo) => todo.text === data.body.text)) {
return invalidInput("body", [
{ code: "custom", path: ["text"], message: "You already have this todo." },
]);
}
const todo = { id: todos.length + 1, text: data.body.text, done: false };
todos.push(todo);
return success({ todo });
}),
toggleTodo: builder
.paramsSchema(z.strictObject({ id: z.number() }))
.patch(async ({ data }) => {
const todo = todos.find((todo) => todo.id === data.params.id)!;
todo.done = !todo.done;
return success({ todo });
}),
clearCompleted: builder.post(async () => {
todos = todos.filter((todo) => !todo.done);
return success({});
}),
};
useAction
An action is your own async function. useAction runs it and gives you its state:
function NewTodo() {
const [text, setText] = useState("");
const create = useAction(
async (text: string) => {
const response = await fetchCuple(client.createTodo.post, {
body: { text },
}).thenKeep(["success", "invalid-body"]);
if (response.result === "success") setText("");
return response;
},
{ refresh: { success: [client.getTodos] } },
);
const issue =
create.value?.result === "invalid-body"
? create.value.issues.find((issue) => issue.path[0] === "text")
: undefined;
return (
<form
onSubmit={(event) => {
event.preventDefault();
create.run(text);
}}
>
<input
value={text}
onChange={(e) => setText(e.target.value)}
readOnly={create.isPending}
aria-busy={create.isPending}
/>
{issue && <p className="error">{issue.message}</p>}
</form>
);
}
In this example:
fetchCupleresolves with every result the server sent.thenKeep(["success", "invalid-body"])keeps success and the one failure this form handles, as typed values; anything else throws. Both the schema's error and the custom one areinvalid-body, withpath: ["text"].create.valueis what the function returned, typed, so the form can show the issue next to the input.refresh: { success: [...] }names what the action changes, and when: the todos are refetched only when one was created. Nothing else is.
Status
status | Meaning |
|---|---|
idle | Nothing has happened yet. |
pending | Running, including the refresh it asked for. The screen keeps the old data until the new data lands. |
done | Your function finished. value is what it returned, including failures you kept, so check value.result. |
failed | Your function threw something nobody handled, and errors.onError sent it to "notify" or null. error is the CupleError. |
run never rejects. It resolves with the resulting state, so it can go straight into a form library's submit handler.
One action per item
function TodoItem({ todo }: { todo: Todo }) {
const toggle = useAction(
() =>
fetchCuple(client.toggleTodo.patch, {
params: { id: todo.id },
}).thenKeepSuccess(),
{ refresh: [client.getTodos] },
);
return (
<li className={toggle.isPending ? "pending" : undefined} aria-busy={toggle.isPending}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggle.run()}
disabled={toggle.isPending}
/>{" "}
{todo.text}
</li>
);
}
- Nothing to handle here but success, so
thenKeepSuccess()is enough: a failure throws, and goes where unhandled errors go. - Call
useActionin the item's component, so each todo has its ownisPending. aria-busyand thependingclass only say what is busy. The stylesheet dims the row and puts a spinner in the button, so the markup stays short and the button keeps its size.
Refresh
Nothing is refreshed unless the action names it. List what a write changes. When the server computes one thing from another (property cards counting permits), list both:
/** The cache. One per app; plain code (sign-in, SSE handlers) uses it directly. */
export const store = createCupleStore();
/** What each write changes, in one place. Actions name these in `refresh`. */
export const refreshes = {
notes: [client.getNotes, client.getNote],
contacts: [client.listContacts, client.getContact],
};
// Switching accounts re-reads only what depends on the account: every call's
// key changed, so each reader reads again under the new one. The previous
// account's data is dropped at once (the default `keepOnKeyChange: false`): the
// next person at this screen may not be the same person.
export function signIn(accountId: AccountId) {
signedInAs = accountId;
store.refreshKeys();
}
// Signing out drops everything: nothing of the account stays in memory.
export function signOut() {
signedInAs = null;
store.clear();
}
/** 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>
);
}
- Every cache entry of a listed endpoint (one per set of arguments) is refetched, and so is every
combinethat read one of them throughget. - Only entries something is reading are refetched. The rest are dropped, and fetched fresh when next read. So when you delete the note an open pane shows, and close the pane inside the action, the deleted note is never refetched and never 404s.
refresh: [...]refreshes every time the function finishes.refresh: { success: [...] }refreshes only for the listed results, asNewTodoabove does; the keys are typed from what the function returns.
Errors
Errors you handle are results your function resolves with, "transport-error" included: typed values in value. Narrow fetchCuple with thenKeep to the ones you handle: a plain fetchCuple resolves every server result, so a 500 would be done, not an error. Anything else your function throws (a result it didn't keep, a network failure it didn't list, a bug) is unhandled, and config.errors.onError decides where it goes: per action, per <Boundary>, or app-wide on the provider:
"boundary": the nearest<Boundary>, as aCupleError. The default."notify":config.errors.notifyshows it; the page stays, and the status becomesfailed.
Errors never end up only in the console. See Errors for all the cases in one app.
Rules of thumb
- When a component moves to another item, give it a
key(key={note.id}), or it shows the previous item's result. - The state is the latest run's. An earlier run that finishes later still writes and refreshes, but doesn't change what is shown.
- Steps that must all happen or none belong in one server route. The client can sequence steps, but it can't make them a transaction.