Skip to content

Data In And Out

Instances parse from JSON in add and serialize back in json() through the same field declarations. Use this page when wiring models to an API: what goes on the wire, how server-assigned ids replace temporary ones, and why nothing breaks in between.

Examples on this page run inside an active scope - see Collections and instances.

Serializing

ts
const t = todos.add({ id: "1", title: "write docs" });

t.json();           // { id: "1", title: "write docs", done: false }
JSON.stringify(t);  // same - toJSON is wired to json()

What goes out:

  • bound fields under their JSON keys, through their output transforms;
  • references as ids, children embedded as objects;
  • local fields, inverse views, and members - never.

Temporary Ids

An instance created without an id gets an autogenerated, temporary one. It works everywhere locally but never serializes:

ts
const t = todos.add({ title: "new" });

t.id;     // "~tmp1" - usable in routes, get(), references
t.json(); // { title: "new", done: false } - no id: the server assigns one

Only ids that came from outside - add input or rebind - appear in json().

Changing An Id: rebind

The optimistic-creation flow end to end:

ts
const t = todos.add({ name });          // temporary id
navigate(`/todo/${t.id}`);              // URL with the temporary id

const saved = await api.save(t.json()); // payload has no id
t.rebind(saved.id);                     // now it has the server one

What happens on rebind:

  • every relation pointing at the instance is rewritten to the new id;
  • the old id keeps resolving: todos.get("~tmp1") returns the same instance, so the open route and any component holding the old id keep working;
  • that forwarding is lookup-only - it never appears in indexes, query results, or json();
  • the forwarding entry dies with the instance.

Id Aliases

Every rebind leaves an alias: the old id keeps finding the instance. That is the only alias mechanism - there is no addAlias, an alias is always the trace of a rename:

ts
const t = todos.add({ id: "draft-7", title: "x" });

t.rebind("42");

todos.get("draft-7") === t; // true - the alias resolves
todos.get("42") === t;      // true - the real id, always wins over aliases
t.json().id;                // "42" - aliases never serialize

The alias rules:

  • resolution is fallback-only: a real id in the collection always wins over an alias with the same spelling;
  • aliases live in get (and useModel(todos.get(...))) only - never in indexes, query results, or relations;
  • an alias dies with its instance, so the table holds live renames, not history;
  • add matches real ids only; an input whose id equals a live alias takes that spelling over as a real id and drops the alias with a dev warning;
  • aliases chain flat: after rebind("A") then rebind("B"), both "A" and the original id point straight at "B" - no chain walks.

Looking an entity up by several identifiers - a slug and a uuid - is not an alias and not a second id. It is a second field:

ts
const Article = staticModel({
  data: {
    title: f.string(),
    slug: f.string().indexed().unique(),
  },
});

articles.where(Article.slug.eq("intro")).first; // lookup by the secondary key

Stable List Keys

instance.key is the identity for UI lists:

tsx
{todos.items.map((t) => <Row key={t.key} todo={t} />)}

It is not the id: it survives rebind (the row does not remount when the server renames the entity) and never repeats when instance slots are reused (rows of different entities are never glued together).

Loading In Any Order

Server payloads rarely arrive in dependency order, and they do not have to:

  • a reference id loaded before its target reads as null, then resolves once the target is added;
  • re-adding a fetched list merges by id - existing instances update in place, references to them stay valid;
  • a children array is authoritative: known ids merge, new entries are created, absent children are disposed, order is taken from the array.

Adapting Foreign Formats

The model keeps one canonical JSON shape. A legacy or secondary format is a function at the edge:

ts
function fromLegacy(row: LegacyRow) {
  return todos.add({ name: row.title_text, done: row.state === 2 });
}

Contract

ts
instance.json(): Record<string, unknown>; // omits a temporary id
instance.toJSON();                        // alias used by JSON.stringify
instance.rebind(newId: string): void;     // rewrites references, keeps forwarding
instance.key: string;                     // stable UI identity

collection.get(oldId); // resolves forwarding after rebind

Common Cases

Use this flow for:

  • optimistic creation - add, navigate, save, rebind;
  • PATCH payloads - json() of an instance the user edited;
  • cache refresh - re-add the fetched list over the live collection;
  • multi-request loading - entities and their references in any order.