Adding reactivity in React
Pick the hook that re-renders when your data changes, edit text without saving on every keystroke, and build the blog editor.
The hooks in @seedprotocol/react read and write the same local SQLite database as the SDK's own APIs (Item.create, post.title = '…'), and re-render your components when the data they show changes. Which hook re-renders on which change decides how you structure lists and forms.
BlogApp, built at the end of this page, renders inside the SeedProvider and SeedClientGate tree from Create the blog app. It uses the Post type and the asBoolean helper from Reading and writing data. The gate mounts its children, hidden, while the client starts. Seed hooks wait for the client on their own, but code that calls the SDK directly when a component mounts, such as Item.all() in a useEffect, should check useIsClientReady() first. React overview documents both components.
How reactivity works
Each model property on an item is backed by an ItemProperty with its own state machine. When you assign post.title = 'New headline', the property updates its in-memory value at once, then validates the value and saves it to SQLite in the background. Hooks subscribe to those state machines, or to live queries on the database, and re-render your components.
There are two subscription paths:
- One item or one property.
useItemanduseItemPropertysubscribe to the state machines of the item and its properties. Any assignment to that item re-renders the components that use it, including assignments made in other components. - Lists.
useItemscombines TanStack React Query with a SQLite live query on the table of items. It refetches when the set of items changes: an item is created or deleted, or an EAS sync adds data. It does not re-render when a field of an existing item changes.
So render each list row with its own useItem (or useItemProperty) when the row shows values that can change. The complete example below does this.
The reactivity model has the full picture, including the events that refresh lists after an EAS sync.
Choosing a hook
Use the smallest subscription that covers what the component displays:
| UI need | Hook | Re-renders when |
|---|---|---|
| Show one field | useItemProperty | That property's value changes |
| Edit a text field | useItem with the text in local state until the user finishes, or useDebouncedItemProperty (see below) | As useItem or useItemProperty |
| Show or edit several fields of one item | useItem | Any property of the item changes |
| Render a list or a count | useItems, with useItem in each row that shows changing fields | Items are created, deleted or synced |
| Filter or sort in SQL | useLiveQuery; see Querying with liveQuery | The query's rows change |
Reading and writing data shows each of these hooks in use, and the React items reference and item properties reference document every parameter and return value.
Write reactively
The write API in React is the same as everywhere else: assign to the item. The assignment saves automatically, and the hooks pick up the change. For a checkbox or a select, assign in the change handler:
import { useItem } from '@seedprotocol/react'
import type { Post } from './types'
import { asBoolean } from './values'
function PublishedToggle({ seedLocalId }: { seedLocalId: string }) {
const { item } = useItem({ modelName: 'Post', seedLocalId })
const post = item as Post | undefined
if (!post) return null
return (
<input
type="checkbox"
checked={asBoolean(post.published)}
onChange={(e) => { post.published = e.target.checked }}
/>
)
}A text input can be bound the same way: an input whose onChange assigns post.title on an item from useItem keeps every keystroke, even with fast typing, and so does one bound to property.value from useItemProperty. A keystroke that breaks a rule, such as a title's 256th character, stays in the input with its error and isn't written. Each keystroke still starts a save, though. Keeping unsaved text in local state avoids that: hold it there while the user types, and assign it when they leave the field or press Enter:
import { useState } from 'react'
import { useItem } from '@seedprotocol/react'
import type { Post } from './types'
function PostTitleInput({ seedLocalId }: { seedLocalId: string }) {
const { item } = useItem({ modelName: 'Post', seedLocalId })
const post = item as Post | undefined
const [draft, setDraft] = useState<string | null>(null) // null: not editing
if (!post) return null
const commit = () => {
if (draft !== null && draft !== (post.title ?? '')) post.title = draft
setDraft(null)
}
return (
<input
value={draft ?? post.title ?? ''}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
/>
)
}The unsaved text exists only while the user is editing. The rest of the time the input shows post.title, so it follows changes from other components and tabs. If the save is rejected, such as a title over 255 characters, nothing is written, and post.title and the input keep the rejected title until the next edit; Validation shows how to read the reasons. Unsaved text is lost if the input unmounts before it loses focus; Editing items optimistically has a fuller useDraftProperty hook that also saves after a pause and on unmount.
useDebouncedItemProperty is another option for text fields. It shows each keystroke at once and saves after 300 ms without typing (pass a second argument to change the delay). A pending edit is saved when the component unmounts, and the hook's flush() saves it straight away:
import { useDebouncedItemProperty } from '@seedprotocol/react'
function DebouncedTitleInput({ seedLocalId }: { seedLocalId: string }) {
const { property, setValue } = useDebouncedItemProperty({ seedLocalId, propertyName: 'title' })
return (
<input
value={String(property?.value ?? '')}
onChange={(e) => setValue(e.target.value)}
/>
)
}A complete example
This layout combines the pieces above: a list of posts, a button that creates one with useCreateItem (from Creating items), and an editor. Each sidebar row uses useItem, so its label follows the title when you finish editing it.
import { useState } from 'react'
import { useCreateItem, useItem, useItems } from '@seedprotocol/react'
import type { Post } from './types'
import { asBoolean } from './values'
export function BlogApp() {
const [selectedId, setSelectedId] = useState<string | null>(null)
return (
<div style={{ display: 'flex', gap: '1rem' }}>
<PostSidebar selectedId={selectedId} onSelect={setSelectedId} />
{selectedId ? <PostEditor seedLocalId={selectedId} /> : <p>Select a post</p>}
</div>
)
}
function PostSidebar({
selectedId,
onSelect,
}: {
selectedId: string | null
onSelect: (id: string) => void
}) {
const { items: posts, isLoading } = useItems({ modelName: 'Post' })
const { createItem, isLoading: isCreating } = useCreateItem()
const handleNew = async () => {
const item = await createItem('Post', { title: 'Untitled', published: false })
if (item) onSelect(item.seedLocalId)
}
if (isLoading) return <p>Loading posts…</p>
return (
<aside>
<button onClick={handleNew} disabled={isCreating}>
{isCreating ? 'Creating…' : 'New post'}
</button>
<ul>
{posts.map((post) => (
<PostListRow
key={post.seedLocalId}
seedLocalId={post.seedLocalId}
isSelected={post.seedLocalId === selectedId}
onSelect={onSelect}
/>
))}
</ul>
</aside>
)
}
function PostListRow({
seedLocalId,
isSelected,
onSelect,
}: {
seedLocalId: string
isSelected: boolean
onSelect: (id: string) => void
}) {
const { item } = useItem({ modelName: 'Post', seedLocalId })
const post = item as Post | undefined
return (
<li>
<button type="button" aria-current={isSelected} onClick={() => onSelect(seedLocalId)}>
{post?.title || 'Untitled'}
</button>
</li>
)
}
function PostEditor({ seedLocalId }: { seedLocalId: string }) {
const { item } = useItem({ modelName: 'Post', seedLocalId })
const post = item as Post | undefined
const [draftTitle, setDraftTitle] = useState<string | null>(null)
if (!post) return <p>Loading post…</p>
const commitTitle = () => {
if (draftTitle !== null && draftTitle !== (post.title ?? '')) post.title = draftTitle
setDraftTitle(null)
}
return (
<main>
<input
value={draftTitle ?? post.title ?? ''}
onChange={(e) => setDraftTitle(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
placeholder="Title"
/>
<label>
<input
type="checkbox"
checked={asBoolean(post.published)}
onChange={(e) => { post.published = e.target.checked }}
/>
Published
</label>
</main>
)
}Type in the title field and press Enter or click elsewhere, and the sidebar label updates. Clicking another post in the sidebar also saves the title first, because the input loses focus before the click. The editor and the row use different useItem calls for the same item, and both subscribe to its title property. BlogApp doesn't check readiness itself: it only uses hooks, and they wait for the client.
Common pitfalls
Importing hooks from the SDK. The hooks and components on this page come from @seedprotocol/react, not @seedprotocol/sdk.
Comparing raw values. Boolean and Number values read back as strings, so if (post.published) is true even for 'false'. See What you get back.
Assuming relations are expanded. post.author is an ID, not a nested object. See Following relations.
Expecting images right away. After a sync, an Image property can exist before its file has been downloaded. See Working with file and image properties.
Where to go next
The next page, Publishing your first item, puts a draft on-chain with @seedprotocol/publish. For feeds and dashboards, see Building a list view that updates in real time; for startup progress, Client and DB status.
Reading and writing data
Query items, follow relations, work with lists and rich property types, and understand how the SDK saves every change locally.
Publishing your first item
Install @seedprotocol/publish, connect a wallet, and publish a blog post from the local database to Arweave and EAS on the OP Sepolia testnet.