Skip to main content

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:

  • fetchCuple resolves 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 are invalid-body, with path: ["text"].
  • create.value is 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​

statusMeaning
idleNothing has happened yet.
pendingRunning, including the refresh it asked for. The screen keeps the old data until the new data lands.
doneYour function finished. value is what it returned, including failures you kept, so check value.result.
failedYour 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 useAction in the item's component, so each todo has its own isPending.
  • aria-busy and the pending class 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 combine that read one of them through get.
  • 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, as NewTodo above 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 a CupleError. The default.
  • "notify": config.errors.notify shows it; the page stays, and the status becomes failed.

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.