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:
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:
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
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.
Related
- Reading and writing data: assignment, validation and value types
- Item properties:
useItemPropertyanduseDebouncedItemProperty - ItemProperty:
value,save()andsaveValidationErrors - Items, actors and the write path and Conflict detection
- Ownership and drafts
- Keep writing while offline
Building a list view that updates in real time
Build a list of posts that follows edits, creates and deletes, with sorting, filtering, loading and empty states.
Publishing and revoking items
Publish edits and new versions, remove published values, publish related items together, estimate costs, confirm large uploads, recover from failed publishes, publish unattended with session keys, and unpublish.