Seed Protocol
Tutorial: build a blog

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:

  1. One item or one property. useItem and useItemProperty subscribe 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.
  2. Lists. useItems combines 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 needHookRe-renders when
Show one fielduseItemPropertyThat property's value changes
Edit a text fielduseItem 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 itemuseItemAny property of the item changes
Render a list or a countuseItems, with useItem in each row that shows changing fieldsItems are created, deleted or synced
Filter or sort in SQLuseLiveQuery; see Querying with liveQueryThe 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.

src/BlogApp.tsx
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.

On this page