Skip to content

CRUD handler contracts

You write the handlers, but the library calls them. Each verb hands your handler a different payload and expects a different return. Getting a contract wrong rarely fails loudly. A patch handler that resolves a diff quietly drops fields. A list handler that resolves rows renders an empty screen.

One pattern underlies every verb. Each handler receives one argument object, a few keys shared by every verb plus a verb-specific payload carrying exactly the identity that operation needs. Each returns a promise, and the instance either assigns its resolved value, ignores it, or holds it open as a connection.

After this page you should be able to say:

  • what any verb's handler receives, before checking the reference
  • what each handler must resolve, and what happens when it resolves the wrong shape
  • where a thrown or rejected handler error lands
  • when to return a cancellable promise instead of a plain one

This page stays on the handler's side of the call. For where the boundary between instance and handler sits, and why transport lives outside the library, see Instances and transport. For how the library decides to cancel and re-run handlers, see Cancellable intents. The how-to guides linked at the end cover writing specific handlers step by step. The examples name an object instance contact, from useObjectInstance, and a list instance contacts, from useListInstance.

One argument object

Every handler receives a single argument object. Two keys are on it for every verb:

  • target: the backend arguments you registered or passed to the instance. Its shape is yours; the library passes it through untouched.
  • pkKey: the name of the primary key field.

Any extra keys you pass to an action call are forwarded as-is. They cannot override the built-in keys. Two more keys ride along on most verbs:

  • params: your listing or retrieval arguments, again in a shape you define. list, subscribe, create, retrieve, update, and patch receive it; delete, bulkDelete, and executeAction do not.
  • isCancelled: a readonly ref that turns true once the run is cancelled. bulkDelete and the list side's executeAction do not receive it.

The exhaustive shapes live in the generated reference, in config/listCrud and config/objectCrud.

What each verb receives

On top of the shared keys, each verb carries its own payload. On the object side:

  • retrieve and delete receive pk, coerced to a string.
  • create receives the new object and no pk. No key exists yet; the backend assigns one.
  • update receives the whole object and no separate pk. The key rides inside the record, at object[pkKey].
  • patch receives pk plus partialObject, the changed fields only.

The asymmetries are deliberate. An update payload already names its record, so a separate key would be redundant. A patch payload is a diff that may not mention the key at all, so the key travels beside it. A handler for update derives the key itself:

javascript
import { cancellableFetch, setObjectCrud } from "@arrai-innovations/reactive-helpers";

setObjectCrud({
    update: ({ target, object, pkKey }) => {
        const pk = object[pkKey];
        return cancellableFetch(
            `/api/${target.resource}/${pk}`,
            { method: "PUT", body: JSON.stringify(object) },
            (response) => response.json()
        );
    },
});

On the list side:

  • list receives no row identity at all. It receives callbacks instead: pushObjects, clearObjects, setPaginateInfo, and setColumnTotals.
  • bulkDelete receives pks, the keys to delete in one request.

Both sides also accept an executeAction handler, an escape hatch for server operations outside the CRUD verbs. It receives target, pkKey, the action name, and the record identity (pks or pk); the reference pages above document each side's exact shape.

What each verb must resolve

The object verbs retrieve, create, update, and patch must resolve the full record. The instance assigns the resolved value into contact.state.object as a mirror, not a merge. Three consequences follow:

  • A resolved record that omits fields drops them from contact.state.object. A patch that resolves only the diff leaves the object holding only the diff. Resolve the complete record.
  • A resolved value that is not an object fails the assignment. The failure lands in contact.state.error and the action resolves false, the same as any handler error.
  • Parsing the response into that record is the handler's job, like the rest of serialization.

delete is the exception on the object side. Its resolved value is ignored; resolving means the delete succeeded.

The list side never assigns resolved values. Rows enter the instance only through the pushObjects callback, and list resolving means the fetch is complete. A list handler that resolves an array of rows changes nothing; the rows are silently discarded. bulkDelete resolving reports success, after which the instance empties the list (see Bulk delete rows for the reload pattern that follows). Why the two sides treat resolved values differently is the central rule of Instances and transport.

The promise the action itself returns is not your handler's promise. A CRUD action such as contact.update(...) or contacts.list() resolves true on success and false on a stored failure. A handler error never rejects the action's promise.

When a handler fails

Reject the promise, or throw inside an async handler. The instance stores the error object unchanged in state.error, sets state.errored to true, clears state.loading, and resolves the action false. Because the error is stored as-is, the typed errors your handler throws reach the component intact. Each action clears the previous error when it starts.

Three failure paths behave differently:

  • A synchronous throw. Only retrieve and list catch an error thrown before the handler produces its promise. On every other verb the throw escapes the action call, and state.loading stays true with no run to clear it. Keep the code before your promise trivial, and let failures reject.
  • A second call while loading. While state.loading is true, a new object action throws an ObjectError with code already-loading rather than storing it. A list action rejects with a ListInstanceError instead. That is a developer error, not a transport failure. The one exception is repeating the call already in flight: a second retrieve or list returns that same promise.
  • A cancelled run. Deliberate cancellation is not a failure. The action resolves false without touching state.error.

Cancellable or plain

The single-record verbs and list may return either a plain promise or one carrying a cancel method (the reference calls this union MaybeCancellablePromise). The choice propagates: the promise an action returns has .cancel only when your handler's promise did.

That method is what lets the library abandon superseded work. When a watched input changes mid-flight, the run is cancelled only if its promise carries a working .cancel. A plain promise runs to completion, and the consequences differ by side. The model, the contract behind a "working" cancel, and those consequences are covered in Cancellable intents. cancellableFetch and makeCancellable build conforming promises, and a cooperative handler also re-checks isCancelled.value after each await.

The subscribe handler

subscribe follows a different contract, because it models a standing connection rather than a single request:

  • It receives the shared keys plus an event applier: applyObjectEvent on the list side, callback on the object side. Each takes (data, action) with action one of create, update, or delete. The object handler also receives pk.
  • The shared arguments arrive differently per side. The list handler's target and params are deep-cloned snapshots taken as the run starts. A long-lived stream reads the values it started with; a rerun takes fresh ones. The object handler receives the live reactive objects.
  • It resolves once your connection is open, not when data arrives. The subscription's state.loading clears on that resolution.
  • Its returned promise must carry cancel, because that cancel is the disconnect. A plain promise leaves the library no way to close your connection, so the listener leaks.
  • It also receives runId and isCurrentRun, so a stream that cannot abort can still drop events from a superseded run (Cancellable intents explains this signal). list and retrieve runs started by a subscription carry the same pair.

Subscription lifecycle covers when the subscribe run starts, stops, and re-runs. Build a live-updating list walks through writing one subscribe handler end to end.

Where to go next