Seed Protocol
Concepts

Reactivity model

How the SDK and the React hooks find out that data changed: actor subscriptions for in-memory edits, live queries and the browser change feed for stored data, and events for syncs and address changes.

Seed has no single "store changed" signal. Changes reach your code through three channels: subscriptions to the state machines behind each object, notices of writes to the local SQLite database (live queries, and in the browser the change feed), and an event bus for EAS syncs and address changes. Each hook listens to one or more of them, and that decides when it re-renders.

Three channels

1. Actor subscriptions: in-memory changes

Every Item and ItemProperty is backed by an XState actor that holds its current state (Items, actors and the write path explains how). When you assign post.title = 'New headline', the title property's actor updates its value at once, before the write reaches the stored rows. Anything subscribed to that actor hears about it in the same tick.

useItem and useItemProperty subscribe this way. That's why an input bound to post.title updates as you type, and why every other component showing the same item updates with it: the SDK keeps one instance per item, so they all subscribe to the same actors.

2. Live queries and the change feed: changes to stored data

A live query is a SQL query that runs again when the tables it reads change. In the browser, the SDK uses SQLocal's reactive queries. SQLocal tracks which tables each write touches, and it also tells other tabs on the same origin about those writes, so a live query in one tab sees saves made in another. On Node, BaseDb.liveQuery has no change tracking: it re-runs the query every second and doesn't accept SQL tag functions (see Querying).

A live query on metadata would re-run on every value written anywhere, so one item's rows are watched through the change feed instead. In the browser, every write to seeds, versions and metadata is logged, each tab reads new log rows when they appear, including rows another tab wrote, and only the watchers of the item that was written read their rows again.

The hooks hear about writes that didn't happen through the object they're watching this way:

  • useItems runs a live query on the seeds table to see when items are created, deleted, synced or published.
  • useLiveQuery watches whatever its query reads. A query that fails keeps its last rows and subscribes again after 0.5, 1, 2, 5 and 10 seconds, then every 30 seconds, until a result arrives; useLiveQueryState returns the error, and a SQL tag function has to be memoised. See useLiveQuery.
  • useItemProperties reads its item's metadata rows again when the change feed reports a write to them, from any tab or from sync, and refetches the list once per save. On Node it polls the rows with a live query.
  • In the browser, each loaded ItemProperty watches its own metadata rows through the change feed, and each Item watches its rows in seeds, versions and metadata. When one is written, for example because another tab saved a new value or an EAS sync wrote one, the object reads it again and its subscribers re-render, so a property value that a sync adds shows on an item that's already loaded. The read includes writes still waiting in the outbox, from any tab, so a value being typed isn't replaced by an older stored one. Items, actors and the write path covers what that loop means for your writes.

The change feed runs only in the browser. On Node (including Electron's main process), a loaded item doesn't hear about writes made elsewhere, such as by another process; load it again to see them. The exception is an EAS sync: after each one, the SDK reads the loaded items again.

3. Events: syncs and address changes

A few changes are announced on the SDK's event bus, and the React hooks refetch when they hear them:

EventMeaningWho reacts
easSeedDataSyncedToDbAn EAS sync wrote datauseItem and useItems refetch. SeedProvider refetches every item list
addresses.persisted, localCopies.removedThe owned or watched addresses differ from the ones this tab last announced, or local copies of on-chain items were removedSeedProvider refetches useItems, useItem and useItemProperty queries and bumps useSeedAddressRevision()

No hook listens for itemProperty.saved. It's emitted when await prop.save() resolves, and only then: assigning post.title = 'x' writes the value without emitting it. useItemProperties hears every write through the change feed.

React Query sits on top

The list hooks (useItems, useItemProperties, useSchemas, useModels and others) cache their results in TanStack React Query. The live queries, change-feed reads and events above don't return data to your components directly. They invalidate a React Query key, React Query runs the hook's fetch again, and the component re-renders with the result.

SeedProvider supplies the QueryClient and subscribes to the events that invalidate it. useItems and useItemProperties throw without it above them. The other hooks fall back to a shared client and miss the refetches after EAS syncs and address changes; see Which hooks need SeedProvider. A bare QueryClientProvider gives the hooks a cache, but nothing refreshes it after EAS syncs or address changes.

useItem also uses React Query, but only to look the item up, and to look it up again after an EAS sync or when the instance it shows stops (the item was destroyed, or another tab dropped it). Once it has the item, its re-renders come from actor subscriptions, not from the cache.

What each hook re-renders on

HookRe-renders whenDoesn't re-render when
useItemA value of any of the item's properties changes, in this component, anywhere else in the tab or in another tab. An EAS sync finishes. The item's uids or publish state change (seedUid, latestVersionUid, revokedAt, publisher and the rest in useItem)A save finishes without changing a value, such as a successful save of a string
useItemPropertyThat property's value changes, or its actor settles after a save. Its property.conflict is recorded or resolved, or its valueStatus changes (a file is found or goes missing)Other properties of the item change
useItemsThe set of item IDs matching the query changes: an item is created, deleted or synced. A listed item is published (the list re-sorts). The owned or watched addresses changeA field of an existing item changes
useItemPropertiesA write to the item's metadata rows changes the list (one refetch per save)The refetch returns the same property instances, as after a save in another tab: their values are current, but the list isn't re-rendered
useLiveQueryThe rows of the query change (compared as JSON)Rows are written but the result is identical

useItems compares IDs on purpose: a list that re-fetched every item on every keystroke in an editor would be slow. The cost is that a row showing post.title from a useItems result goes stale when the title changes. Render each row with its own useItem or useItemProperty, as in Adding reactivity in React.

Values settle into strings

The SDK stores every value as text, so once a save finishes the property's value is the stored string: right after post.wordCount = 42 it's the number 42, and a moment later it's '42'. Components re-render on that change, so a === comparison can flip from true to false after an edit; convert values when you read them, as in What you get back.

Across tabs

Each tab has its own actors and React Query cache. Live queries and the change feed see other tabs' writes through SQLocal, and the SDK forwards some events, but not itemProperty.saved, to other tabs; see Keeping other tabs current and Events across tabs.

What this means for your code

Do

  • Pick the smallest subscription that covers what a component shows: useItemProperty for one field, useItem for a form, useItems plus a useItem per row for lists.
  • Convert Boolean, Number and list values when you read them, not once when you load them.
  • When you keep data outside the hooks, such as a search index, watch the item's rows with watchEntityRows in the browser, or use a live query or easSeedDataSyncedToDb.

Don't

  • Don't expect useItems rows to follow field edits.
  • Don't rely on itemProperty.saved to see every edit.
  • Don't replace SeedProvider with a plain QueryClientProvider.

On this page