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/reactThe 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:
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>,
)SeedClientGatecallsclient.init(initConfig)and shows a loading screen until the client is ready. Its children stay mounted but hidden in the meantime.SeedProvidersupplies 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:
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
| Name | Type | Default | Description |
|---|---|---|---|
children | ReactNode | Your app, or the part of it that uses Seed hooks. Rendered straight away, whether or not the client is ready | |
config | SeedConfig | Calls 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) => void | Logs [SeedProvider] client init failed to the console | Called when the provider's client.init() rejects. See Handling init errors |
queryClient | QueryClient | A new client from createSeedQueryClient() | Use your own client, so Seed and the rest of your app share one cache |
queryClientRef | MutableRefObject<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 event | Effect |
|---|---|
| EAS data synced to the local database | Refetches 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.removed | Refetches 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.ElementProps
| Name | Type | Default | Description |
|---|---|---|---|
initConfig | SeedConstructorOptions | Required. Passed to client.init(): { config, addresses?, syncFromEasOnAddressChange?, … }. See Client and configuration | |
schema | string | SchemaFileInput | A schema object or OPFS path. When set, it replaces initConfig.config.schema | |
loadingComponent | ReactNode | A centred "Loading..." | What to show while the client starts |
onInitError | (err: unknown) => void | Logs [SeedClientGate] client init failed to the console | Called when client.init() rejects. Children stay hidden behind the loading UI |
wrapperClassName | string | Class for the outer wrapper. Without it, the wrapper gets inline styles: position: relative, display: flex, 100vh by 100vw | |
loadingClassName | string | Class for the loading overlay. Without it, the overlay is absolutely positioned over the wrapper and centres its content | |
children | ReactNode | Your app | |
onSchemaImportError | (err: unknown) => void | Deprecated, 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 comparesschemaandinitConfig, leaving out itsaddresses, by value (withJSON.stringify), so an inlineinitConfig={{ 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 toaddressesdoesn't run init again: see Changing addresses.- A different
configmakesclient.init()throwINIT_CONFIG_CONFLICT_MESSAGE(exported from@seedprotocol/sdk), which reachesonInitError, 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 reachesidle, whoever started it, and hides them again fromclient.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;missinglists 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>): QueryClientParameters
| Name | Type | Default | Description |
|---|---|---|---|
overrides | Partial<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:
| Option | Value |
|---|---|
queries.networkMode | 'offlineFirst' |
queries.staleTime | 60_000 (1 minute) |
queries.gcTime | 86_400_000 (24 hours) |
getSeedQueryDefaultOptions
Returns a copy of Seed's default options (the table above), as TanStack DefaultOptions.
function getSeedQueryDefaultOptions(): DefaultOptionsmergeSeedQueryDefaults
Merges your default options over Seed's. Your values win. Use it when you build the QueryClient yourself.
function mergeSeedQueryDefaults(userOptions?: Partial<DefaultOptions> | null): DefaultOptionsParameters
| Name | Type | Default | Description |
|---|---|---|---|
userOptions | Partial<DefaultOptions> | null | Your 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(): numberReturns
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
| Page | Exports |
|---|---|
| This page | SeedProvider, SeedClientGate, createSeedQueryClient, getSeedQueryDefaultOptions, mergeSeedQueryDefaults, useSeedAddressRevision, ADDRESSES_PERSISTED_EVENT, LOCAL_COPIES_REMOVED_EVENT |
| Client and DB status | useGlobalServiceStatus, useIsClientReady, useClientService, useClientSelector, useDbsAreReady, useIsDbReady, isDbReadyState, useServices, useService, the getService* helpers, usePersistedSnapshots, useHasSavedSnapshots |
| Schemas | useSchema, useSchemas, useCreateSchema, useDestroySchema, useAllSchemaVersions, useSeedProtocolSchema |
| Models | useModels, useModel, useCreateModel, useDestroyModel |
| Model properties | useModelProperties, useModelProperty, useCreateModelProperty, useDestroyModelProperty |
| Items | useItem, useItems, useCreateItem, useDeleteItem, getItemQueryKey |
| Item properties | useItemProperty, useItemProperties, useDebouncedItemProperty, useCreateItemProperty, useDestroyItemProperty, getItemPropertyQueryKey, invalidateItemPropertiesForItem |
| useLiveQuery | useLiveQuery, useLiveQueryState: reactive SQL and Drizzle queries against the local database |
| Publishing and EAS sync | usePublishItem, useEasSyncProcesses, useEasSyncProcessesState, useEasSyncProcessesNonActiveCount, useEasSyncProcessById, EasSyncProcessRecord |
| Images and media | SeedImage, useEnsureLocalImage, useResolvedMediaRef, the SeedMedia* components, SeedHtml, SeedJson, formatSeedJson, useNormalizedFeedItemFields |
| Files and OPFS | useFiles, 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 }, whereerroris anErrorornull. - 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.
Related
- Add Seed to your app: the full project setup
- Adding reactivity in React: how the hooks stay up to date, and which one to use
- @seedprotocol/sdk overview: the classes these hooks wrap