Seed Protocol
@seedprotocol/react

Overview and setup

What @seedprotocol/react provides, how SeedProvider and SeedClientGate start the Seed client, and the React Query helpers.

@seedprotocol/react is the React binding for the Seed SDK. It gives you two setup components, SeedProvider and SeedClientGate, and hooks that read and write the SDK's local database and re-render your components when data changes. The hooks wrap the same Schema, Model, Item and ItemProperty classes you'd use from @seedprotocol/sdk directly.

import { SeedProvider, SeedClientGate } from '@seedprotocol/react'

Install

npm install @seedprotocol/sdk @seedprotocol/react

The package needs React and react-dom 18 or 19, @tanstack/react-query 5 (peers of @seedprotocol/react, which npm, pnpm and bun install for you) and the SDK at the same version as @seedprotocol/react. It declares that exact SDK version as a peer, so npm refuses a mismatched pair with ERESOLVE. Add Seed to your app has the commands for pnpm and bun and the full setup, including the Vite plugin and production headers.

The package's bundle starts with 'use client', so a React Server Components app can import SeedProvider and the other components into a server component such as Next.js's app/layout.tsx, without a client wrapper file. @seedprotocol/next has the rest of the Next.js setup.

Setup

The setup in Add Seed to your app and the tutorial renders SeedProvider with SeedClientGate inside it:

src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { SeedProvider, SeedClientGate } from '@seedprotocol/react'
import { config } from './seed'
import App from './App'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <SeedProvider>
      <SeedClientGate initConfig={{ config }}>
        <App />
      </SeedClientGate>
    </SeedProvider>
  </StrictMode>,
)
  • SeedClientGate calls client.init(initConfig) and shows a loading screen until the client is ready. Its children stay mounted but hidden in the meantime.
  • SeedProvider supplies the React Query client the list hooks use and keeps their caches in step with SDK events.

Seed hooks wait for the client on their own. Code that calls the SDK directly when a component mounts, such as Item.all() in a useEffect, has to wait itself, with useIsClientReady.

SeedProvider with config

The shorter form drops the gate and passes config to SeedProvider, which then calls client.init() itself:

src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { SeedProvider } from '@seedprotocol/react'
import { config } from './seed'
import App from './App'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <SeedProvider config={config}>
      <App />
    </SeedProvider>
  </StrictMode>,
)

Your components render straight away, with no loading screen. Seed hooks return loading or empty results until the client is ready, then update; useItems, for example, reports isLoading: true until then. Code that calls the SDK directly has to wait with useIsClientReady, as it does under the gate. If init fails, the hooks go on reporting loading or empty results, so show the failure from onInitError. Use SeedClientGate when you want a loading screen in front of the app.

PublishProvider from @seedprotocol/publish renders SeedProvider for you. Pass the client config as its seedConfig (with addresses and onInitError if you need them) and it starts the client; without seedConfig, start the client yourself, for example with SeedClientGate inside it. See Publish components and hooks.

Changing addresses

SeedProvider's addresses prop and initConfig.addresses on SeedClientGate work the same way. The value at mount goes to client.init(). When the values change later, the component calls client.setAddresses() with them after its init settles, and applies several changes in order. It compares values with JSON.stringify, so an inline array with the same values does nothing.

A change to [] or { owned: [] } clears the stored owned and watched addresses, so a host that passes [] for a moment while a connected wallet loads loses them. Leaving the prop out changes nothing, so leave it out until the wallet is known:

import type { ReactNode } from 'react'
import { SeedProvider } from '@seedprotocol/react'
import { config } from './seed'

export function SeedRoot({ wallet, children }: { wallet?: string; children: ReactNode }) {
  return (
    <SeedProvider config={config} addresses={wallet ? [wallet] : undefined}>
      {children}
    </SeedProvider>
  )
}

To sign out, pass [] or call client.setAddresses([]).

A failed client.setAddresses() is logged with console.error, as [SeedProvider] setAddresses failed or [SeedClientGate] setAddresses failed, and doesn't reach onInitError. If the component's init failed, the change is dropped.

A remount changes no addresses, whatever the prop holds. The new mount's client.init() is a repeat init, which leaves the stored addresses alone, and the component treats the mount's value as applied, so nothing is logged and re-rendering with that value does nothing. A remount with [] keeps a connected wallet's addresses, and a remount with another user's addresses keeps the previous user's. If your app unmounts the provider or gate across a user switch, call client.setAddresses() with the new addresses. After client.stop(), the next mount is a first init and applies its value, and a first init with [] clears the stored addresses.

Which hooks need SeedProvider

useItems and useItemProperties throw No QueryClient set, use QueryClientProvider to set one when no SeedProvider is mounted above them.

useItem, useItemProperty, useDebouncedItemProperty, useModels, useModel, useSchemas, useModelProperties, useFiles and useImageFiles work without it: they fall back to one shared client with Seed's defaults, and miss the refreshes SeedProvider runs after EAS syncs and address changes. In development builds, the first hook to use the fallback logs one warning for the page: [@seedprotocol/react] A Seed hook ran outside SeedProvider (no QueryClientProvider found), so it uses a shared fallback QueryClient. Mount SeedProvider above them to get the refreshes and drop the warning.

The other hooks don't use React Query and work the same with or without it: useModelProperty, useSchema, the create and destroy hooks, the status hooks, useLiveQuery and useLiveQueryState.

SeedProvider

Provides a TanStack QueryClient to Seed's hooks, subscribes to SDK events so cached results stay current, and starts the client when you pass config.

function SeedProvider(props: SeedProviderProps): JSX.Element

type SeedProviderProps = {
  children: ReactNode
  config?: SeedConfig
  addresses?: SeedConstructorOptions['addresses']
  onInitError?: (err: unknown) => void
  queryClient?: QueryClient
  queryClientRef?: React.MutableRefObject<QueryClient | null>
}

Props

NameTypeDefaultDescription
childrenReactNodeYour app, or the part of it that uses Seed hooks. Rendered straight away, whether or not the client is ready
configSeedConfigCalls client.init({ config, addresses }) in an effect after the provider mounts. Leave it out when SeedClientGate or your own code calls client.init(). The effect runs again when the config object changes. The SDK compares configs deeply, so a new object with the same contents does nothing, and one with different contents rejects with INIT_CONFIG_CONFLICT_MESSAGE, which reaches onInitError. Define it outside the component. See Client and configuration
addresses{ owned: string[]; watched?: string[] } or string[]Passed to client.init() with config; ignored without it, later changes included. A later change goes through client.setAddresses(), and a change to [] clears the stored addresses. Leaving the prop out keeps them, and a remount changes nothing. See Changing addresses
onInitError(err: unknown) => voidLogs [SeedProvider] client init failed to the consoleCalled when the provider's client.init() rejects. See Handling init errors
queryClientQueryClientA new client from createSeedQueryClient()Use your own client, so Seed and the rest of your app share one cache
queryClientRefMutableRefObject<QueryClient | null>Receives the client in use. Meant for tests: it also sets window.__TEST_SEED_QUERY_CLIENT__

What it does

SeedProvider renders a QueryClientProvider, so don't add another one around Seed hooks. It also listens for:

SDK eventEffect
EAS data synced to the local databaseRefetches every useItems list
addresses.persisted (when client.setAddresses(), or client.init() with addresses, saves addresses that differ from the ones this tab last announced) and localCopies.removedRefetches useItems, useItem and useItemProperty queries, and bumps useSeedAddressRevision()

Example

Share one cache with the rest of your app:

import { SeedProvider, createSeedQueryClient } from '@seedprotocol/react'

const queryClient = createSeedQueryClient({
  defaultOptions: { queries: { retry: 1 } },
})

export function Root({ children }: { children: React.ReactNode }) {
  return <SeedProvider queryClient={queryClient}>{children}</SeedProvider>
}

Create the client outside the component, or memoize it. A new queryClient on each render gives you a new, empty cache each time.

Don't replace SeedProvider with a plain QueryClientProvider. The list hooks would run, but nothing would refresh them after EAS syncs or address changes.

SeedClientGate

Starts the Seed client and hides its children until the client is ready.

function SeedClientGate(props: SeedClientGateProps): JSX.Element

Props

NameTypeDefaultDescription
initConfigSeedConstructorOptionsRequired. Passed to client.init(): { config, addresses?, syncFromEasOnAddressChange?, … }. See Client and configuration
schemastring | SchemaFileInputA schema object or OPFS path. When set, it replaces initConfig.config.schema
loadingComponentReactNodeA centred "Loading..."What to show while the client starts
onInitError(err: unknown) => voidLogs [SeedClientGate] client init failed to the consoleCalled when client.init() rejects. Children stay hidden behind the loading UI
wrapperClassNamestringClass for the outer wrapper. Without it, the wrapper gets inline styles: position: relative, display: flex, 100vh by 100vw
loadingClassNamestringClass for the loading overlay. Without it, the overlay is absolutely positioned over the wrapper and centres its content
childrenReactNodeYour app
onSchemaImportError(err: unknown) => voidDeprecated, and never called. Schemas load during init through config.schema or the schema prop. A schema that fails to load fails init (Could not load config.schema from "<path>": <reason>), which reaches onInitError

Behavior

  • The gate renders the loading overlay and your children side by side, and toggles them with display: none.
  • client.init() runs in an effect that compares schema and initConfig, leaving out its addresses, by value (with JSON.stringify), so an inline initConfig={{ config }} calls it once, however often the gate re-renders. A value JSON can't encode is compared by identity instead. The SDK merges overlapping calls. A change to addresses doesn't run init again: see Changing addresses.
  • A different config makes client.init() throw INIT_CONFIG_CONFLICT_MESSAGE (exported from @seedprotocol/sdk), which reaches onInitError, and the gate goes on showing your children on the old config. await client.stop() before switching configs; After client.stop() shows how. In development, editing the config under hot reload hits the conflict until you reload the page.
  • The gate doesn't retry a failed init on its own. To retry, call client.init() again yourself. The gate shows your children as soon as the client reaches idle, whoever started it, and hides them again from client.stop() until the next init finishes.

Handling init errors

onInitError works the same on SeedProvider and SeedClientGate. Besides schema errors, it receives these classes, all exported from @seedprotocol/sdk:

  • FileSystemLockedError (code === 'OPFS_LOCKED') when another tab or worker holds the app's browser storage.
  • TabLockTimeoutError (code === 'TAB_LOCK_TIMEOUT') when another tab held the database's migration or init lock for more than 60 seconds.
  • DatabaseNotPersistentError (code === 'DB_NOT_PERSISTENT') when the page isn't cross-origin isolated. A retry won't fix it: the server has to send the headers.
  • UnsupportedBrowserError (code === 'BROWSER_UNSUPPORTED') when the browser lacks a feature Seed needs; missing lists them. A retry won't help: ask the user to update the browser.

Offer a retry for the first two:

import { useState } from 'react'
import { client, FileSystemLockedError, TabLockTimeoutError } from '@seedprotocol/sdk'
import { SeedClientGate } from '@seedprotocol/react'
import { config } from './seed'

const initConfig = { config }

export function Root({ children }: { children: React.ReactNode }) {
  const [initError, setInitError] = useState<unknown>(null)

  const retry = () => {
    setInitError(null)
    client.init(initConfig).catch(setInitError)
  }

  return (
    <SeedClientGate
      initConfig={initConfig}
      onInitError={setInitError}
      loadingComponent={
        initError instanceof FileSystemLockedError || initError instanceof TabLockTimeoutError ? (
          <p>
            This app is open in another tab. Close it, then{' '}
            <button onClick={retry}>try again</button>.
          </p>
        ) : initError ? (
          <p>Could not start: {String(initError)}</p>
        ) : (
          <p>Starting…</p>
        )
      }
    >
      {children}
    </SeedClientGate>
  )
}

Multi-tab behavior explains when storage is locked.

createSeedQueryClient

Creates a QueryClient with Seed's default options. SeedProvider uses it when you don't pass queryClient.

function createSeedQueryClient(overrides?: Partial<QueryClientConfig>): QueryClient

Parameters

NameTypeDefaultDescription
overridesPartial<QueryClientConfig>Any QueryClient config. defaultOptions is merged over Seed's with mergeSeedQueryDefaults; the other fields are passed through

Returns

A new QueryClient. Seed's defaults are:

OptionValue
queries.networkMode'offlineFirst'
queries.staleTime60_000 (1 minute)
queries.gcTime86_400_000 (24 hours)

getSeedQueryDefaultOptions

Returns a copy of Seed's default options (the table above), as TanStack DefaultOptions.

function getSeedQueryDefaultOptions(): DefaultOptions

mergeSeedQueryDefaults

Merges your default options over Seed's. Your values win. Use it when you build the QueryClient yourself.

function mergeSeedQueryDefaults(userOptions?: Partial<DefaultOptions> | null): DefaultOptions

Parameters

NameTypeDefaultDescription
userOptionsPartial<DefaultOptions> | nullYour queries and mutations defaults. With nothing passed, you get Seed's defaults

The merge is one level deep: queries fields are merged over Seed's queries, and mutations over Seed's mutations.

Example

import { QueryClient } from '@tanstack/react-query'
import { mergeSeedQueryDefaults } from '@seedprotocol/react'

const queryClient = new QueryClient({
  defaultOptions: mergeSeedQueryDefaults({ queries: { gcTime: 1000 * 60 * 60 } }),
})

useSeedAddressRevision

Returns a counter that goes up each time the user's addresses change (saved by client.setAddresses(), or by client.init() with addresses, and different from the ones this tab last announced) or local copies are removed. Saving the same addresses again, as wallets do on every connect, doesn't move it.

function useSeedAddressRevision(): number

Returns

0 at first, then 1, 2, … after each change. It stays 0 outside SeedProvider. Each change re-renders every component that calls the hook. useItems includes the counter in its query key, so addressFilter: 'owned' lists update after a wallet change; useItem and useItemProperty refetch because SeedProvider invalidates their queries on the same events. Use the counter in your own effects that depend on the current addresses:

import { useEffect } from 'react'
import { useSeedAddressRevision } from '@seedprotocol/react'

function useOnAddressChange(callback: () => void) {
  const revision = useSeedAddressRevision()
  useEffect(() => {
    if (revision > 0) callback()
  }, [revision])
}

ADDRESSES_PERSISTED_EVENT and LOCAL_COPIES_REMOVED_EVENT

The names of the two SDK events that bump the counter, for code that listens on the SDK's eventEmitter directly. @seedprotocol/sdk exports constants with the same names and values; Events lists their payloads.

const ADDRESSES_PERSISTED_EVENT: 'addresses.persisted'
const LOCAL_COPIES_REMOVED_EVENT: 'localCopies.removed'

Hook reference

PageExports
This pageSeedProvider, SeedClientGate, createSeedQueryClient, getSeedQueryDefaultOptions, mergeSeedQueryDefaults, useSeedAddressRevision, ADDRESSES_PERSISTED_EVENT, LOCAL_COPIES_REMOVED_EVENT
Client and DB statususeGlobalServiceStatus, useIsClientReady, useClientService, useClientSelector, useDbsAreReady, useIsDbReady, isDbReadyState, useServices, useService, the getService* helpers, usePersistedSnapshots, useHasSavedSnapshots
SchemasuseSchema, useSchemas, useCreateSchema, useDestroySchema, useAllSchemaVersions, useSeedProtocolSchema
ModelsuseModels, useModel, useCreateModel, useDestroyModel
Model propertiesuseModelProperties, useModelProperty, useCreateModelProperty, useDestroyModelProperty
ItemsuseItem, useItems, useCreateItem, useDeleteItem, getItemQueryKey
Item propertiesuseItemProperty, useItemProperties, useDebouncedItemProperty, useCreateItemProperty, useDestroyItemProperty, getItemPropertyQueryKey, invalidateItemPropertiesForItem
useLiveQueryuseLiveQuery, useLiveQueryState: reactive SQL and Drizzle queries against the local database
Publishing and EAS syncusePublishItem, useEasSyncProcesses, useEasSyncProcessesState, useEasSyncProcessesNonActiveCount, useEasSyncProcessById, EasSyncProcessRecord
Images and mediaSeedImage, useEnsureLocalImage, useResolvedMediaRef, the SeedMedia* components, SeedHtml, SeedJson, formatSeedJson, useNormalizedFeedItemFields
Files and OPFSuseFiles, FILES_QUERY_KEY_PREFIX, useImageFiles, useOPFSFiles, OPFSFilesManager, OPFS_FILES_MANAGER_CSS, useStorageEstimate, useThumbnail

Return shapes

Most hooks follow two patterns:

  • Read hooks return { <thing>, isLoading, error }, where error is an Error or null.
  • Mutation hooks return { <action>, isLoading, error, resetError }.

There are exceptions, called out on each page. useAllSchemaVersions, useLiveQuery and useEasSyncProcesses return their data directly rather than an object, and useLiveQueryState returns { data, error } with no isLoading. useSchema returns schema: null when it has nothing, where other single-entity hooks return undefined. And the create functions for schemas, models and item properties return the new instance synchronously, while create from useCreateModelProperty and createItem from useCreateItem are async.

On this page