Seed Protocol
@seedprotocol/react

Items

Hooks for loading one item, listing a model's items, and creating and deleting items from React components.

These hooks read and write items in the app's local SQLite database. useItem and useItems keep components in step with the database, and useCreateItem and useDeleteItem wrap Item.create() and item.destroy() with loading and error state. To edit an item's fields, assign to them on the item; see Reading and writing data.

import { useItem, useItems, useCreateItem, useDeleteItem, getItemQueryKey } from '@seedprotocol/react'

useItem and useItems wait for the client to be ready before they query. useItems needs a SeedProvider above it and throws without one. useItem works without one on a shared fallback query client, logs a warning once in development builds, and doesn't refresh when the user's addresses change, so keep it inside SeedProvider too.

The examples use the Post type and the asBoolean helper from Reading and writing data.

useItem

Loads one item by model name and ID, and re-renders when any of its property values change, whichever component made the change.

function useItem<T>(props: {
  modelName: string
  seedLocalId?: string
  seedUid?: string
}): {
  item: IItem<T> | undefined
  isLoading: boolean
  error: Error | null
}

Parameters

NameTypeDefaultDescription
modelNamestringrequiredThe item's model, for example 'Post'.
seedLocalIdstringThe local ID. Takes precedence when both IDs are set.
seedUidstringThe EAS attestation UID (0x…), for items that came from the chain.

While neither ID is set, the hook doesn't query and returns item: undefined with isLoading: false. That makes it safe to call with an empty relation.

Returns

FieldTypeDescription
itemIItem<T> | undefinedThe item, or undefined while loading or when nothing matches.
isLoadingbooleantrue until the first lookup finishes, and again while the item reloads.
errorError | nullThe lookup's error, or Error('Item service error') when the item's state machine fails.

Behavior

  • Cached items come back at once. If the item is already loaded in memory, the first render returns it with isLoading: false, and the hook refreshes it in the background.
  • Changing the ID never shows the old item. When seedLocalId or seedUid changes, item is undefined until the new one loads.
  • Re-renders on value changes. The hook subscribes to each of the item's properties, so an assignment such as post.title = '…' anywhere in the app re-renders every component that uses useItem for that item.
  • Re-renders after EAS sync. When a sync writes new data for synced items, the hook refetches.
  • Re-renders on identity and publish-state changes. While the item is idle, a change to its seedUid, latestVersionUid, latestVersionLocalId, lastVersionPublishedAt, attestationCreatedAt, revokedAt or publisher re-renders the hook, whether it comes from sync, another tab, a publish recorded after it finished, or an unpublish.
  • Holds the item while mounted. Each mounted useItem holds the item it shows and releases the hold a tick after it unmounts or shows another item, so a StrictMode remount keeps the same instance. An item that no hook or caller holds unloads after the grace period (entityCache.unloadGraceMs in SeedConfig, 60 seconds by default). Writes to an unloaded instance throw EntityUnloadedError; reads return its last values. To keep using the item after the component unmounts, hold it yourself with Item.find({ hold: true }) or Item.retain(item), and call unload() when you're done.
  • Looks the item up again when the instance stops. When the instance it shows stops, because another tab or a local purge removed it or its last hold was released elsewhere, the hook fetches the item again and shows the new instance.

The returned IItem type doesn't list your model's fields. Cast it to your own type, as below.

Example

import { useItem } from '@seedprotocol/react'
import type { Post } from './types'

function PostHeading({ seedLocalId }: { seedLocalId: string }) {
  const { item, isLoading, error } = useItem({ modelName: 'Post', seedLocalId })
  const post = item as Post | undefined

  if (isLoading) return <p>Loading…</p>
  if (error) return <p>{error.message}</p>
  if (!post) return <p>Post not found</p>

  return <h1>{post.title || 'Untitled'}</h1>
}

useItems

Lists the items of a model, newest first. The list refreshes when items are created, deleted or synced.

function useItems(props: {
  modelName?: string
  schemaName?: string
  modelFileId?: string
  deleted?: boolean
  includeEas?: boolean
  addressFilter?: 'owned' | 'watched' | 'all'
}): {
  items: IItem<any>[]
  isLoading: boolean
  error: Error | null
}

Parameters

NameTypeDefaultDescription
modelNamestringThe model to list. Leave it out to list items of every model.
schemaNamestringWith modelName, only that schema's model, for when several schemas define a model with the same name.
modelFileIdstringThe model's ID (Model.id). An alternative to schemaName.
deletedbooleanfalsetrue lists deleted items instead (for a trash view).
includeEasbooleanfalsetrue also lists items that have an EAS attestation UID: published items and items synced from EAS.
addressFilter'owned' | 'watched' | 'all''owned' keeps the items the user owns, by the SDK's ownership rule: drafts that have never had a publisher, and items whose publisher (or, when that's blank, attester) is an owned address. 'watched' keeps items whose publisher is one of the watched addresses. 'all' or leaving it out applies no filter.

Returns

FieldTypeDescription
itemsIItem<any>[]The items, newest first. [] until the first fetch finishes.
isLoadingbooleantrue until the client is ready and the first fetch has finished.
errorError | nullThe fetch error, if any.

Behavior

  • Local drafts only by default. Without includeEas: true, items that have a seedUid are left out, so an item drops out of the list once it's published. Pass includeEas: true to list drafts and on-chain items together.
  • Sorting. Items are sorted by the latest published version's time, falling back to the attestation time and then createdAt, newest first. The times come from the live query, so when a listed item is published or republished the list re-sorts and items is a new array.
  • What triggers a refresh. The hook runs a live query on the seeds table and refetches only when the set of item IDs changes: an item is created or deleted, or an EAS sync adds items. An item created while the first fetch is still running is listed once that fetch finishes. Editing a field of an existing item doesn't re-render the list. Render each row with useItem or useItemProperty when the row shows fields that can change.
  • Revoked items are left out unless deleted is true.
  • Address changes. Inside SeedProvider, the list refetches when the user's owned or watched addresses change (client.setAddresses(), or client.init() with addresses) or local copies are removed. Saving the same addresses again, in another order or letter case, doesn't refetch.
  • Holds each listed item while mounted. Each mounted useItems holds every item in its list and releases an item's hold when the item leaves the list or the hook unmounts. The list's query is dropped as soon as no mounted hook uses it, so a remount fetches the list again, with new instances for items that unloaded in between. When a listed instance stops, the hook fetches the list again.
  • Unloaded fields. Fields on list items are read from each item's properties and can be undefined until those load. Normalise List fields before use, for example Array.isArray(x) ? x : [].

Before the client is ready, the query doesn't run and the hook returns items: [] with isLoading: true, so check isLoading before showing an empty state.

items keeps its identity while the listed items and their publish times are unchanged, so it can be an effect or useMemo dependency.

Deleting the last item in a list leaves it on screen. The hook ignores a live-query result with no rows while it still holds items, so the list doesn't refetch. Remounting the component, or invalidating ['seed', 'items'] after the delete (see getItemQueryKey), clears it.

Example

import { useItem, useItems } from '@seedprotocol/react'
import type { Post } from './types'

function PostList() {
  const { items, isLoading, error } = useItems({ modelName: 'Post', includeEas: true })

  if (isLoading) return <p>Loading…</p>
  if (error) return <p>Could not load posts: {error.message}</p>
  if (items.length === 0) return <p>No posts yet</p>

  return (
    <ul>
      {items.map((item) => (
        <PostRow key={item.seedLocalId} seedLocalId={item.seedLocalId} />
      ))}
    </ul>
  )
}

function PostRow({ seedLocalId }: { seedLocalId: string }) {
  const { item } = useItem({ modelName: 'Post', seedLocalId })
  const post = item as Post | undefined
  return <li>{post?.title || 'Untitled'}</li>
}

A trash view lists deleted items:

import { useItems } from '@seedprotocol/react'

function Trash() {
  const { items: trashed } = useItems({ modelName: 'Post', deleted: true })
  return <p>{trashed.length} deleted post(s)</p>
}

useCreateItem

Returns a createItem function that creates an item with Item.create(), and tracks its loading and error state.

function useCreateItem(): {
  createItem: (
    modelName: string,
    itemData?: Record<string, any>,
    options?: ItemCreateOptions,
  ) => Promise<Item<any> | undefined>
  isLoading: boolean
  error: Error | null
  resetError: () => void
}

The return type is exported as UseCreateItemReturn. ItemCreateOptions is exported from @seedprotocol/sdk.

createItem parameters

NameTypeDefaultDescription
modelNamestringrequiredThe model to create an item of.
itemDataRecord<string, any>{}Initial property values, for example { title: 'Hello' }. Image, File and Html values are saved to files before createItem resolves, as in Item.create().
optionsItemCreateOptionsPassed to Item.create(): validate, waitForReady, readyTimeout and hold (Item.create parameters). validate: false writes the values unchecked. hold defaults to false here, where Item.create() defaults to true; hold: true keeps the item loaded until you call item.unload().

Returns

FieldTypeDescription
createItemfunctionResolves to the new Item, or undefined on failure.
isLoadingbooleantrue while any create is running, until the last one finishes.
errorError | nullThe last failure. Cleared when the next create starts.
resetError() => voidSets error back to null.

createItem never rejects. On failure it resolves to undefined, sets error, and leaves nothing written: Item.create removes the item and any files it saved before it rejects. While a create is still running, a call with the same modelName, the same itemData and the same options returns the first call's promise and creates nothing, even when both calls happen before a re-render (a double click, or two calls in one handler). Any other call creates its own item. Values and options are compared key by key: arrays and plain objects by their contents, so a List value built in the handler still matches, and anything else with Object.is, so a File, Blob or class instance matches only if it's the same object. Both calls must have the same keys; a key set to undefined in one call and absent from the other counts as a different call. Disable the button while isLoading is true, as the example below does, so values that don't match, such as a File the handler builds on each click, can't create a second item. When a create succeeds, useItems lists the new item on its own.

Unless you pass hold: true, nothing holds the new item. Keep its seedLocalId, as the example below does, and render the item with useItem, which holds it while mounted. An instance kept in state or a ref with no hook showing it unloads after the grace period, and the next assignment to it throws EntityUnloadedError. With hold: true it stays loaded until you call item.unload().

error is one of these (Item.create validation has the details; the three error classes are exported from @seedprotocol/sdk):

  • ItemCreateValidationError for keys the model doesn't have and values that break their property's type or validation rules, Image, File and Html values included, unless options.validate is false. Its problems array lists each one as { property, code, message }.
  • UnknownModelError when there's no model called modelName.
  • ItemCreateError when saving an Image, File or Html value fails for another reason. Its code is 'save_failed', with the save's own error in cause (a URL the browser can't fetch gives a TypeError: Failed to fetch), or 'property_not_loaded' when the property didn't load in time. property names the value and seedLocalId is the removed item's.

A URL that answers with an error status, such as a 404, doesn't fail: the response body is saved as the file.

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

function CreateError({ error }: { error: Error | null }) {
  if (error instanceof ItemCreateValidationError) {
    return (
      <ul role="alert">
        {error.problems.map((p, i) => (
          <li key={i}>
            {p.property}: {p.message}
          </li>
        ))}
      </ul>
    )
  }
  if (error instanceof ItemCreateError) {
    return <p role="alert">Couldn't save {error.property}. Check the file and try again.</p>
  }
  return error ? <p role="alert">{error.message}</p> : null
}

required isn't checked. A Date property value should be an ISO 8601 string. See What you get back for how each type reads back.

Example

import { useCreateItem } from '@seedprotocol/react'

function NewPostButton({ onCreated }: { onCreated: (id: string) => void }) {
  const { createItem, isLoading, error, resetError } = useCreateItem()

  const handleClick = async () => {
    const post = await createItem('Post', {
      title: 'Untitled',
      publishedAt: new Date().toISOString(),
    })
    if (post) onCreated(post.seedLocalId)
  }

  return (
    <>
      <button disabled={isLoading} onClick={handleClick}>
        {isLoading ? 'Creating…' : 'New post'}
      </button>
      {error && (
        <p role="alert">
          {error.message} <button onClick={resetError}>Dismiss</button>
        </p>
      )}
    </>
  )
}

useDeleteItem

Returns a deleteItem function that deletes an item with item.destroy(), and tracks its loading and error state.

function useDeleteItem(): {
  deleteItem: (item: Item<any>) => Promise<void>
  isLoading: boolean
  error: Error | null
  resetError: () => void
}

The return type is exported as UseDeleteItemReturn.

Returns

FieldTypeDescription
deleteItem(item: Item<any>) => Promise<void>Deletes the item. Does nothing if item is falsy.
isLoadingbooleantrue while the delete runs.
errorError | nullThe last failure.
resetError() => voidSets error back to null.

Deleting is a soft delete: the item is marked as deleted in SQLite, drops out of useItems, and shows up in useItems({ deleted: true }). It doesn't affect anything already attested on-chain.

When destroy() fails, for example because the item isn't the user's or the database write fails, deleteItem sets error and rejects, so catch the promise.

deleteItem is typed to take the Item class, while useItem and useItems return the IItem interface. Cast with item as Item<any> (import the type from @seedprotocol/sdk).

Example

import type { Item } from '@seedprotocol/sdk'
import { useDeleteItem, useItem } from '@seedprotocol/react'

function DeletePostButton({ seedLocalId }: { seedLocalId: string }) {
  const { item } = useItem({ modelName: 'Post', seedLocalId })
  const { deleteItem, isLoading, error } = useDeleteItem()

  if (!item) return null

  return (
    <>
      <button
        disabled={isLoading}
        onClick={() => deleteItem(item as Item<any>).catch(() => {})}
      >
        {isLoading ? 'Deleting…' : 'Delete post'}
      </button>
      {error && <p role="alert">{error.message}</p>}
    </>
  )
}

getItemQueryKey

The React Query key that useItem uses for one item. Use it to force a refetch from your own code.

function getItemQueryKey(id: string | undefined): readonly ['seed', 'item', string | null]

Pass the same ID you gave useItem (seedLocalId, or seedUid when there is no local ID). useItem already refetches after EAS syncs and address changes, so you rarely need this.

import { useQueryClient } from '@tanstack/react-query'
import { getItemQueryKey } from '@seedprotocol/react'

function RefreshButton({ seedLocalId }: { seedLocalId: string }) {
  const queryClient = useQueryClient()
  return (
    <button onClick={() => queryClient.invalidateQueries({ queryKey: getItemQueryKey(seedLocalId) })}>
      Refresh
    </button>
  )
}

useItems keys start with ['seed', 'items']. The rest of that key isn't public, so invalidate by the prefix: queryClient.invalidateQueries({ queryKey: ['seed', 'items'] }).

On this page