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:
useItemsruns a live query on theseedstable to see when items are created, deleted, synced or published.useLiveQuerywatches 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;useLiveQueryStatereturns the error, and a SQL tag function has to be memoised. See useLiveQuery.useItemPropertiesreads its item'smetadatarows 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
ItemPropertywatches its ownmetadatarows through the change feed, and eachItemwatches its rows inseeds,versionsandmetadata. 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:
| Event | Meaning | Who reacts |
|---|---|---|
easSeedDataSyncedToDb | An EAS sync wrote data | useItem and useItems refetch. SeedProvider refetches every item list |
addresses.persisted, localCopies.removed | The owned or watched addresses differ from the ones this tab last announced, or local copies of on-chain items were removed | SeedProvider 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
| Hook | Re-renders when | Doesn't re-render when |
|---|---|---|
useItem | A 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 |
useItemProperty | That 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 |
useItems | The 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 change | A field of an existing item changes |
useItemProperties | A 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 |
useLiveQuery | The 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:
useItemPropertyfor one field,useItemfor a form,useItemsplus auseItemper row for lists. - Convert
Boolean,Numberand 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
watchEntityRowsin the browser, or use a live query oreasSeedDataSyncedToDb.
Don't
- Don't expect
useItemsrows to follow field edits. - Don't rely on
itemProperty.savedto see every edit. - Don't replace
SeedProviderwith a plainQueryClientProvider.
Related
- Adding reactivity in React: the hooks in practice, and which one to use
- Items, actors and the write path: the objects these subscriptions attach to, and how a write travels to SQLite and back
- Multi-tab behavior
- React items and React item properties
- useLiveQuery and Querying
- Events and SeedProvider
- Querying with liveQuery and Building a list view that updates in real time
Items, actors and the write path
Why Seed objects behave like live views rather than plain data, and the path every write takes from assignment to an in-memory actor, through the outbox to SQLite and back.
Multi-tab behavior
How browser tabs of one app share a single SQLite database and OPFS tree: the locks every tab takes, the leader tab that runs background work, how other tabs stay current, the multiTab modes, and the errors users can see.