Async ownership and cancellation
Choose route loaders, resources, actions, forms, Deferred boundaries, and controller actions by lifetime and cancellation behavior.
Choose the owner before the primitive
Use a route load export when navigation cannot render the route correctly without the data.
Use
createResourcewhen one mounted feature owns independently refreshable read data.Use a route action for a server mutation and its HTTP response.
Use
createActionorcreateFormActionfor observable client mutation state.Use
TavoController.action for controller-owned work whose pending/result/error state should rerender that component.Use
Deferredwhen the server can send meaningful fallback HTML before a secondary value resolves.
Give a component resource explicit cleanup
load always starts a new operation and aborts the previous one. preload deduplicates the current pending operation.
Forward the supplied signal to fetch and every abort-aware dependency.
A stale completion cannot overwrite a newer load.
An abort resolves the resource back to idle, clears error and
updatedAt, and preserves the last data. reset also clears data.Resourcefailures become error state; load resolves to that state instead of rethrowing the loader error.
tsximport {
createResource,
createTavo,
TavoController,
} from "@tavojs/core";
type Activity = {
id: string;
summary: string;
};
class ActivityController extends TavoController {
activity = createResource<Activity[]>(async ({ signal }) => {
const response = await fetch("/api/activity", { signal });
if (!response.ok) {
throw new Error("Activity could not be loaded.");
}
return response.json();
});
onMount() {
this.listen(this.activity.store, () => {
this.model.patch({});
});
this.cleanup(() => {
this.activity.abort("Activity panel unmounted.");
});
void this.activity.load();
}
reload() {
void this.activity.load();
}
}
export const ActivityPanel = createTavo({
controller: ActivityController,
view({ controller }) {
const activity = controller?.activity.read();
if (!activity || activity.status === "idle") {
return <p>Activity is idle.</p>;
}
if (activity.status === "loading") {
return <p aria-busy="true">Loading activity…</p>;
}
if (activity.status === "error") {
return <p role="alert">Activity failed to load.</p>;
}
return (
<ul>
{activity.data?.map((item) => {
return <li key={item.id}>{item.summary}</li>;
})}
</ul>
);
},
});Know whether a mutation rejects
The two client action primitives intentionally expose different caller behavior. Choose based on who owns control flow, then handle both the observable state and returned promise.
createAction.runresolves toActionStateon success or handler failure. The failure is stored with status error.TavoController.action.runresolves with the handler result and rethrows a caught error while also exposing reactive error state.createFormActionmirrorscreateActionstate and also records submitted values.Starting a newer run prevents an older completion from replacing the latest observable state.
reset invalidates an in-flight completion and clears the complete state.
Handle controller action rejection
A controller action is safe to create as a class field.
Its pending, result, and error transitions rerender the owning
createTavocomponent.Catch or await run from event handlers so a handled UI failure does not become an unhandled rejection.
tsxclass SaveController extends TavoController {
save = this.action(async (name: string) => {
const response = await fetch("/projects", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name }),
});
if (!response.ok) {
throw new Error("Project could not be saved.");
}
return response.json() as Promise<{ id: string }>;
});
submit(name: string) {
void this.save.run(name).catch(() => {
// The view renders save.error.
});
}
}Propagate cancellation into Deferred work
Deferred owns rendering of a promise-backed value; it does not create the underlying request. Pass one AbortSignal through createDeferredValue or Deferred and into the operation that produces the promise.
Give every
Deferredvalue a stable id when the server result should be serialized and reused during hydration.Use
timeoutFallbackfor the typedTAVO_DEFERRED_TIMEOUTcase anderrorFallbackfor other rejection.A pure CSR document renders fallback for a promise-backed
Deferredvalue; progressive patching is an SSR capability.Do not share request-specific promises through module variables or process-wide registries.
Test race, abort, and cleanup paths
Hold two operations pending, resolve the newer one first, and assert the older completion cannot replace state.
Abort before start, while pending, and during component unmount.
Assert the semantic difference between abort and reset, especially whether previous data remains.
Reject every action/resource path and verify both observable state and promise behavior.
For server mutations, retry with the same idempotency key and verify one committed effect.
Look up exact public types
Follow linked API names to their canonical TypeScript declarations and package boundaries.