Seed Protocol
Guides

Editing items optimistically

Build a form that edits an item in place: instant local updates, saves that don't lose keystrokes, validation messages, read-only items, and edits arriving from other tabs.

Edits in Seed are local and optimistic by default. Assigning post.title = 'New title' updates the value in memory at once, and the SDK validates and saves it to the local database in the background. There's no save button to wire up and no server round trip to wait for.

The assignment never throws. If validation fails, nothing is written: the value in memory keeps the rejected text, and the reasons go to the property's saveValidationErrors. Items, actors and the write path follows a write from assignment to SQLite and back.

You'll need: the blog schema and the React setup from Reading and writing data, and its Post type and asBoolean helper.

For a checkbox or a select, assign in the change handler, as the tutorial's PublishedToggle does. Read a Boolean through asBoolean: it's true right after the assignment and 'true' once the save settles.

Hold unsaved text in the input

A text input bound straight to the item, or to property.value from useItemProperty, keeps every keystroke, but each keystroke starts a save. Keep the text in local state while the user types, and write it to the item after a pause, when the input loses focus, and when the component unmounts. That's one save per pause, and the hook can tell the user when another tab changed the value while they were typing:

src/useDraftProperty.ts
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { ItemPropertySaveValidationError } from '@seedprotocol/sdk'
import { useItemProperty } from '@seedprotocol/react'

const asText = (value: unknown) => (value == null ? '' : String(value))

export function useDraftProperty(seedLocalId: string, propertyName: string, delayMs = 400) {
  const { property } = useItemProperty({ seedLocalId, propertyName })
  const stored = asText(property?.value)

  const [unsaved, setUnsaved] = useState<string | null>(null) // null: not editing
  const unsavedRef = useRef<string | null>(null)
  const baseRef = useRef<string | null>(null) // the value the unsaved text is based on
  const propertyRef = useRef(property)
  propertyRef.current = property
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
  const writes = useRef(0) // only the latest write's outcome counts
  const leaving = useRef(false) // the input lost focus and nothing was typed since
  const [, rerender] = useReducer((n: number) => n + 1, 0)

  const stopEditing = useCallback(() => {
    unsavedRef.current = null
    baseRef.current = null
    setUnsaved(null)
  }, [])

  const flush = useCallback(() => {
    clearTimeout(timer.current)
    const current = propertyRef.current
    const value = unsavedRef.current
    if (!current || value === null) return
    const write = ++writes.current
    current.value = value // starts the save
    baseRef.current = value
    current.save().then(
      () => {
        if (write === writes.current && leaving.current) stopEditing()
      },
      (err) => {
        if (write !== writes.current) return
        // Text that broke a rule stays on screen with its error, also after the field loses focus
        if (err instanceof ItemPropertySaveValidationError) return rerender()
        // Other failures, such as an OwnershipError, show the stored value again: that isn't a
        // change from elsewhere. Drop the unsaved text once the field loses focus
        if (leaving.current) return stopEditing()
        baseRef.current = null
        rerender()
      },
    )
  }, [stopEditing])

  useEffect(() => flush, [flush]) // write on unmount

  const onChange = (value: string) => {
    if (unsavedRef.current === null) baseRef.current = stored
    leaving.current = false
    unsavedRef.current = value
    setUnsaved(value)
    clearTimeout(timer.current)
    timer.current = setTimeout(flush, delayMs)
  }

  const onBlur = () => {
    leaving.current = true
    if (unsavedRef.current === null) return
    flush() // stops editing once the save succeeds
  }

  return {
    property,
    value: unsaved ?? stored,
    onChange,
    onBlur,
    changedElsewhere: unsaved !== null && baseRef.current !== null && stored !== baseRef.current,
  }
}

Use it for each text field:

import { useDraftProperty } from './useDraftProperty'

function TitleField({ seedLocalId }: { seedLocalId: string }) {
  const { value, onChange, onBlur } = useDraftProperty(seedLocalId, 'title')
  return <input value={value} onChange={(e) => onChange(e.target.value)} onBlur={onBlur} />
}

While the user types, the input shows the unsaved text and nothing is saved. After 400 ms without typing, the text is assigned to the property, which saves it, and every other component showing the title updates. Leaving the field or unmounting the component writes it immediately. The write on unmount works because the property instance outlives the component.

The hook waits on save() for each write. When the text fails validation, the property keeps it unwritten, with its errors, and so does the input, also after the user leaves the field, so they can fix it rather than type it again. Once a save succeeds after the user leaves the field, the input shows the stored value again. Any other rejection, such as an OwnershipError, makes the property show the stored value again; the input keeps the unsaved text while it has focus and drops it when the field loses focus.

useDebouncedItemProperty from @seedprotocol/react is a shorter alternative: it shows each keystroke at once, saves after a pause and on unmount, and returns flush() to save sooner. It gives you no changedElsewhere (see below), and on an item the user doesn't own the typed text disappears when the save fails: the input shows the stored value again and the hook's error is the OwnershipError. See Item properties.

Show validation errors

A rejected save leaves the reasons on the property. useItemProperty re-renders when the save settles, so read property.saveValidationErrors there:

import { useDraftProperty } from './useDraftProperty'

function TitleField({ seedLocalId }: { seedLocalId: string }) {
  const { property, value, onChange, onBlur } = useDraftProperty(seedLocalId, 'title')
  const errors = property?.saveValidationErrors ?? []

  return (
    <>
      <input
        value={value}
        aria-invalid={errors.length > 0}
        onChange={(e) => onChange(e.target.value)}
        onBlur={onBlur}
      />
      {errors.map((error) => (
        <p key={error.code} role="alert">{error.message}</p>
      ))}
    </>
  )
}

If the schema gives title a validation: { maxLength: 80 }, typing an 81st character shows Text must be 80 characters or less. Fixing the text clears the message on the next save. Text has a 255-character limit when no maxLength is set, and going past it shows Text properties have a default limit of 255 characters. Set validation.maxLength for a higher limit, or use an Html property for long content.

Read errors from useItemProperty, which re-renders when a save settles. useItem re-renders only when a value changes, and a rejected save changes none, so its errors don't reach it.

The rejected text stays in the input with its error until the user fixes it. post.title and every other component show it too, but it isn't stored: after a reload the item has the stored value. Don't treat what's on screen as saved.

For a flow that needs to know the outcome, such as "Save and close", assign and then wait on save(). It resolves once the save has settled, and throws ItemPropertySaveValidationError with a validationErrors array when validation failed:

import { ItemPropertySaveValidationError } from '@seedprotocol/sdk'

titleProperty.value = newTitle
try {
  await titleProperty.save()
  close()
} catch (err) {
  if (err instanceof ItemPropertySaveValidationError) {
    showErrors(err.validationErrors.map((e) => e.message))
  } else {
    throw err
  }
}

Validation when creating an item

Item.create checks initial values with the same rules. An unknown key or an invalid value rejects the whole create with ItemCreateValidationError, and nothing is written. Its problems list each one:

import { Item, ItemCreateValidationError } from '@seedprotocol/sdk'

const values = { modelName: 'Post', title: newTitle }
try {
  await Item.create(values)
} catch (err) {
  if (err instanceof ItemCreateValidationError) {
    showErrors(err.problems.map((p) => `${p.property}: ${p.message}`))
  } else {
    throw err
  }
}

useCreateItem runs the same checks; it resolves undefined and puts the error in its error field instead of throwing. A problem's code names the check it failed, such as type, max_length_exceeded, enum_violation or unknown_property (Item.create: Validation lists them all), so you can switch on it and show message.

Make items you don't own read-only

An item is the user's to edit when it's a draft that has never had a publisher, or when its publisher is one of the owned addresses. On any other item, save(), destroy() and publish() reject with an OwnershipError (Item is read-only: you do not own this item), and an assignment's write fails: nothing is written, and the property shows the stored value again.

A refused assignment doesn't throw. The input shows the typed value until the write fails, then the stored value comes back; a save() awaited after the assignment rejects with the OwnershipError. That flicker is why it's worth checking ownership before showing editable inputs.

isItemOwned from @seedprotocol/sdk applies the same rule as save(). It's async, so wrap it in a hook, and check again when the user's addresses change:

src/useIsOwned.ts
import { useEffect, useState } from 'react'
import { isItemOwned } from '@seedprotocol/sdk'
import { useGlobalServiceStatus, useSeedAddressRevision } from '@seedprotocol/react'

export function useIsOwned(seedLocalId: string | undefined): boolean | undefined {
  const { status } = useGlobalServiceStatus()
  const isReady = status === 'idle'
  const addressRevision = useSeedAddressRevision()
  const [owned, setOwned] = useState<boolean | undefined>(undefined)

  useEffect(() => {
    if (!seedLocalId || !isReady) return
    let cancelled = false
    setOwned(undefined)
    isItemOwned({ seedLocalId }).then(
      (result) => { if (!cancelled) setOwned(result) },
      () => { if (!cancelled) setOwned(false) },
    )
    return () => {
      cancelled = true
    }
  }, [seedLocalId, isReady, addressRevision])

  return owned
}

isItemOwned reads the database, so the hook waits until the client is ready; called earlier, it throws Db not configured. Components under SeedClientGate mount before the client is ready, so the check matters there too. useSeedAddressRevision changes when client.setAddresses() is called, and needs SeedProvider. With no owned addresses set, only drafts that have never had a publisher are owned: published items and drafts with a publisher are read-only. Ownership and drafts explains the rule and how to claim drafts after a wallet connects.

Handle edits from another tab

All tabs of an app share one local database, and in the browser each loaded property hears other tabs' writes through the change feed. When another tab saves the title, this tab's post.title changes to the new value and its components re-render.

With an input bound directly to the property, that includes the input the user is typing in: the other tab's value can replace their text. With useDraftProperty, the input keeps showing the unsaved text while the user is editing, and the other tab's value shows up everywhere else. When the unsaved text is written, after a pause or on blur, it replaces the other tab's value: the last write wins. The SDK records no conflict between tabs. See Conflict detection.

changedElsewhere from the hook is true when the stored value changed while the user was editing. A rejected or refused write of their own doesn't set it. Use it to warn them before their text overwrites someone else's change:

const { value, onChange, onBlur, changedElsewhere } = useDraftProperty(seedLocalId, 'title')

{changedElsewhere && <p role="status">This title was changed in another tab. Your edit will replace it.</p>}

Complete example

src/PostEditor.tsx
import { useItem } from '@seedprotocol/react'
import type { Post } from './types'
import { asBoolean } from './values'
import { useDraftProperty } from './useDraftProperty'
import { useIsOwned } from './useIsOwned'

export function PostEditor({ seedLocalId }: { seedLocalId: string }) {
  const { item, isLoading } = useItem({ modelName: 'Post', seedLocalId })
  const post = item as Post | undefined
  const owned = useIsOwned(seedLocalId)

  if (isLoading || owned === undefined) return <p>Loading…</p>
  if (!post) return <p>Post not found</p>

  if (!owned) {
    return (
      <article>
        <h1>{post.title || 'Untitled'}</h1>
        <p>{post.summary}</p>
        <p>You can't edit this post.</p>
      </article>
    )
  }

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <TextField seedLocalId={seedLocalId} propertyName="title" label="Title" />
      <TextField seedLocalId={seedLocalId} propertyName="summary" label="Summary" />
      <label>
        <input
          type="checkbox"
          checked={asBoolean(post.published)}
          onChange={(e) => { post.published = e.target.checked }}
        />
        Published
      </label>
    </form>
  )
}

function TextField({
  seedLocalId,
  propertyName,
  label,
}: {
  seedLocalId: string
  propertyName: string
  label: string
}) {
  const { property, value, onChange, onBlur, changedElsewhere } = useDraftProperty(
    seedLocalId,
    propertyName,
  )
  const errors = property?.saveValidationErrors ?? []

  return (
    <div>
      <label>
        {label}
        <input
          value={value}
          aria-invalid={errors.length > 0}
          onChange={(e) => onChange(e.target.value)}
          onBlur={onBlur}
        />
      </label>
      {errors.map((error) => (
        <p key={error.code} role="alert">{error.message}</p>
      ))}
      {changedElsewhere && <p role="status">Changed in another tab. Your edit will replace it.</p>}
    </div>
  )
}

Mount the editor next to the list from Building a list view that updates in real time: the row for the post being edited updates after each pause in typing.

Gotchas

An edit snaps back with no error. The item isn't owned: it's published, or a draft with a publisher, and that publisher isn't in the owned list. The assignment's write failed and the property shows the stored value again; a save() on the property rejects with OwnershipError. See Ownership and drafts.

A number input flips type. Number values are numbers right after assignment and numeric strings once saved. Convert when reading, and parse the input's string before assigning: post.wordCount = Number(e.target.value).

Nothing listens for my edits. itemProperty.saved is emitted by save() only, not by assignment. Subscribe with useItem, useItemProperty or a live query instead.

Copying values into state. Don't keep the stored value in useState outside an active edit. A copy doesn't follow edits from other components, other tabs or a sync. useDraftProperty holds unsaved text only while the user is editing, and text that failed validation until it's fixed.

On this page