Navigated to /docs/core/async-ownership

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 createResource when one mounted feature owns independently refreshable read data.

  • Use a route action for a server mutation and its HTTP response.

  • Use createAction or createFormAction for observable client mutation state.

  • Use TavoController.action for controller-owned work whose pending/result/error state should rerender that component.

  • Use Deferred when 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.

  • Resource failures become error state; load resolves to that state instead of rethrowing the loader error.

TSX
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.run resolves to ActionState on success or handler failure. The failure is stored with status error.

  • TavoController.action.run resolves with the handler result and rethrows a caught error while also exposing reactive error state.

  • createFormAction mirrors createAction state 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 createTavo component.

  • Catch or await run from event handlers so a handled UI failure does not become an unhandled rejection.

TSX
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 Deferred value a stable id when the server result should be serialized and reused during hydration.

  • Use timeoutFallback for the typed TAVO_DEFERRED_TIMEOUT case and errorFallback for other rejection.

  • A pure CSR document renders fallback for a promise-backed Deferred value; 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.