SemiLayerDocs

React Hooks

The @semilayer/react package is a thin wrapper around @semilayer/client that exposes React hooks for search, similarity, direct queries, streaming, and live tail.

Installation

npm install @semilayer/react @semilayer/client

Requires React 19+. The hooks rely on transitional concurrent-mode features and do not polyfill them for older versions.

Provider

Wrap your tree once with <SemiLayerProvider>. It constructs a shared BeamClient and makes it available to every hook below it.

import { SemiLayerProvider } from '@semilayer/react'

export default function Root({ children }: { children: React.ReactNode }) {
  return (
    <SemiLayerProvider
      baseUrl="https://api.semilayer.com"
      apiKey={process.env.NEXT_PUBLIC_SEMILAYER_KEY!}
    >
      {children}
    </SemiLayerProvider>
  )
}

For SSR or tests, you can pass a pre-built client directly:

import { BeamClient } from '@semilayer/client'
import { SemiLayerProvider } from '@semilayer/react'

const client = new BeamClient({ baseUrl, apiKey })

<SemiLayerProvider client={client}>{children}</SemiLayerProvider>
ℹ️

Use a public key (pk_live_...) in the browser. Service keys (sk_live_...) bypass access rules and must never leave the server.


One-shot Hooks

These mirror the three HTTP methods on BeamClient. They run once on mount (and again when the params change), resolve a Promise, and expose { data, loading, error, refetch }.

useSearch

useSearch<M>(
  lens: string,
  params: SearchParams | null,
  opts?: { enabled?: boolean },
): { data: SearchResponse<M> | null; loading: boolean; error: Error | null; refetch: () => void }
import { useSearch } from '@semilayer/react'

function Search({ query }: { query: string }) {
  const { data, loading, error } = useSearch<Product>('products', query ? { query } : null)

  if (loading) return <Spinner />
  if (error) return <Error message={error.message} />
  return <Results results={data?.results ?? []} />
}

Passing null (or omitting params.query) keeps the hook idle — useful for deferring the request until the user types.

useSimilar

useSimilar<M>(
  lens: string,
  params: SimilarParams | null,
  opts?: { enabled?: boolean },
): { data: SimilarResponse<M> | null; loading; error; refetch }
const { data } = useSimilar<Product>('products', { id: productId, limit: 5 })

useQuery

useQuery<M>(
  lens: string,
  params?: QueryParams | null,
  opts?: { enabled?: boolean },
): { data: QueryResponse<M> | null; loading; error; refetch }
const { data, refetch } = useQuery<Product>('products', {
  where: { category: 'footwear' },
  orderBy: { field: 'price', dir: 'asc' },
  limit: 20,
})

params serialises via JSON.stringify for dependency tracking, so the hook refetches whenever the shape changes.


All four hooks above — plus useStreamSearch / useStreamQuery below — accept an optional include field that asks SemiLayer to stitch declared relations onto each parent row. The planner batches a cross-source fetch per relation (Postgres + MySQL + Mongo, whatever you configured) and the hook returns parent rows with the relations already attached.

import { useQuery } from '@semilayer/react'

function ProductList() {
  const { data } = useQuery<Product>('products', {
    where: { category: 'footwear' },
    include: {
      reviews: { limit: 3, orderBy: { field: 'rating', dir: 'desc' } },
      brand: true,
    },
  })

  return data?.rows.map((p) => (
    <article key={p.id}>
      <h3>{p.name}</h3>
      <p>By {p.brand?.name}</p>
      <ul>
        {p.reviews?.map((r) => <li key={r.id}>{r.body}</li>)}
      </ul>
    </article>
  ))
}

Failures on individual relations land in meta.includeErrors; the relation value on each row is [] (hasMany) or null (belongsTo) — you can render "reviews unavailable" placeholders without the parent list disappearing.

Full reference at Joins → Include Syntax.


Streaming Hooks

These open a WebSocket via BeamClient and progressively populate local state as rows / events arrive. The socket is shared across all streaming hooks mounted under the same provider.

useStreamSearch

Progressive-render a search result set.

useStreamSearch<M>(
  lens: string,
  params: StreamSearchParams | null,
  opts?: { enabled?: boolean },
): {
  results: SearchResult<M>[]
  loading: boolean
  done: boolean
  error: Error | null
  reset: () => void
}
import { useStreamSearch } from '@semilayer/react'

function LiveSearch({ query }: { query: string }) {
  const { results, done } = useStreamSearch<Product>(
    'products',
    query ? { query, limit: 200 } : null,
  )
  return (
    <ul>
      {results.map((r) => (
        <li key={r.id}>{r.metadata.name}</li>
      ))}
      {!done && <li>Loading more…</li>}
    </ul>
  )
}

useStreamQuery

Progressive-render a direct query.

useStreamQuery<M>(
  lens: string,
  params: StreamQueryParams | null,
  opts?: { enabled?: boolean },
): { rows: M[]; loading; done; error; reset }

useSubscribe

Live tail — append every insert / update / delete event that matches the lens (and optional filter).

useSubscribe<M>(
  lens: string,
  params?: SubscribeParams | null,
  opts?: { enabled?: boolean; maxEvents?: number },
): {
  events: StreamEvent<M>[]
  latest: StreamEvent<M> | null
  error: Error | null
  reset: () => void
}
import { useSubscribe } from '@semilayer/react'

function LiveFeed() {
  const { latest } = useSubscribe<Product>('products')
  return latest ? <Toast event={latest} /> : null
}

The events array is capped at maxEvents (default 500) to keep memory bounded.

useObserve

Observe a single record. The first emitted value is the current state; subsequent emissions fire when the record changes.

useObserve<M>(
  lens: string,
  recordId: string | null,
  opts?: { enabled?: boolean },
): { record: M | null; loading: boolean; error: Error | null }
const { record } = useObserve<Product>('products', productId)

Cleanup & Cancellation

Every hook cancels in-flight work on unmount or param change:

  • One-shot hooks guard state updates behind a cancelled flag so late responses are dropped after unmount.
  • Streaming hooks call iterator.return() on cleanup, which closes the underlying WebSocket op cleanly — no zombie streams.

End-User Identity (RBAC)

For per-user access rules, build a client with withUser() and pass it to the provider. Doing this per render is cheap — withUser clones the client without opening a new socket.

import { BeamClient } from '@semilayer/client'
import { SemiLayerProvider } from '@semilayer/react'

function AppWithUser({ userToken, children }: { userToken: string; children: React.ReactNode }) {
  const shared = useMemo(
    () => new BeamClient({ baseUrl, apiKey }),
    [],
  )
  const perUser = useMemo(() => shared.withUser(userToken), [shared, userToken])

  return <SemiLayerProvider client={perUser}>{children}</SemiLayerProvider>
}

useFeed

The magical surface for the feed facet. Takes a codegen-emitted feed handle directly (not via the provider context). Owns the lifecycle: first-page fetch on mount, refetch on context change, optional live tick subscription, context evolution via the customer-named evolve() action.

import { useFeed } from '@semilayer/react'
import { beam } from './generated/beam'

function PostsFeed() {
  const [context, setContext] = useState({ likedIds: [] as string[] })

  const { items, fetchMore, refetch, evolve, liveCount, evolved, isLoading, cursor } = useFeed(
    beam.posts.feed.discover,
    {
      context,
      pageSize: 12,
      liveUpdates: true,
      onEvolve: async (delta) => {
        // Persist the interaction to the customer's own API.
        await fetch('/api/interactions', {
          method: 'POST',
          body: JSON.stringify(delta.meta),
        })
      },
    },
  )

  // Card click handler — caller-defined "interaction" vocabulary
  const onPositive = (id: string) => {
    evolve({
      contextDelta: { likedIds: [...context.likedIds, id] },
      setContext,
      meta: { kind: 'positive', recordId: id },
    })
  }

  return (
    <div>
      {liveCount > 0 && (
        <button onClick={refetch} className="pill">
          ✨ {liveCount} new
        </button>
      )}
      {items.map((item) => (
        <Card key={item.sourceRowId} item={item} onPositive={onPositive} />
      ))}
      {cursor && (
        <button onClick={fetchMore} disabled={isLoading}>
          {isLoading ? 'Loading…' : 'Load more'}
        </button>
      )}
    </div>
  )
}

Options

OptionTypeDescription
contextFeedContextCaller-controlled context. Hook re-fetches when its stable JSON shape changes.
pageSizenumberOverride the configured pageSize.
liveUpdatesbooleanOpen feed.subscribe on mount; accumulate newCount into liveCount. Default false.
onEvolve(delta) => void | Promise<void>Called when evolve() fires. Use to persist the interaction to your own API.
evolveOn'change' | 'never'Auto-refetch behavior on evolve(). Default 'change'.
cacheFeedCache | nullPer-call cache override. Falls through to BeamClient's feedCache. Pass null to disable.
onCacheMetric(event) => voidForwarded to the cache. Wire into your telemetry.

Returns

FieldTypeDescription
itemsFeedItem<M>[]Accumulated items across fetchMore calls.
fetchMore() => Promise<void>Load the next page. No-op when cursor === null.
refetch() => Promise<void>Reset state + re-fetch the first page.
evolve(delta) => Promise<void>Apply an interaction. See below.
liveCountnumberRunning count from feed.tick since last refetch.
evolvedbooleanTrue when server reports a contextHash change. UI cue.
isLoadingbooleanUnderway: the most recent fetch.
errorError | nullMost recent error, if any.
cursorstring | nullCurrent cursor. Null when no more pages.

evolve(delta) — the interaction action

interface EvolveDelta {
  contextDelta?: Partial<FeedContext>      // What to merge into context
  setContext?: (next: FeedContext) => void // Caller's setState (sugar — apply contextDelta immediately)
  meta?: Record<string, unknown>           // Forwarded to onEvolve so the app knows what happened
}

The hook never names a specific interaction. The customer's app supplies meta ({ kind: 'positive' }, { kind: 'follow' }, { kind: 'save' }, whatever vocabulary the product uses). The customer's onEvolve callback persists however they want.

The action sequence:

  1. If setContext + contextDelta supplied → apply immediately (next render's effect refetches).
  2. Fire onEvolve (awaited).
  3. If evolveOn === 'change' and no setContext was supplied → refetch first page manually.

When the user has many signals

Past ~50-100 signals, both the wire and the math degrade. Trim at your data layer — the customer owns what's important to surface. Three patterns in Signals: recency window, pre-computed taste vector, or recordVector mode (no scaling concern at all).

See Also