@semilayer/react
v1.7.1
Published
SemiLayer React hooks — useSearch, useSimilar, useQuery, useStreamSearch, useStreamQuery, useSubscribe, useObserve, useFeed, useAnalyze + headless chart-shape utilities
Readme
Install
pnpm add @semilayer/react @semilayer/clientThe basics — useSearch
import { SemiLayerProvider, useSearch } from '@semilayer/react'
function App() {
return (
<SemiLayerProvider apiKey={process.env.NEXT_PUBLIC_SEMILAYER_KEY!}>
<Search />
</SemiLayerProvider>
)
}
function Search() {
const { data, loading } = useSearch('articles', { query: 'renewable energy' })
if (loading) return <Spinner />
return <ul>{data?.map((r) => <li key={r.id}>{r.title}</li>)}</ul>
}Feeds — useFeed
The magical surface for the feed facet. Takes a
codegen-emitted feed handle directly. 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 { useState } from 'react'
import { useFeed } from '@semilayer/react'
import { beam } from './generated/beam'
export function PostsFeed() {
const [context, setContext] = useState({ likedIds: [] as string[] })
const { items, fetchMore, refetch, evolve, liveCount, cursor, isLoading } = useFeed(
beam.posts.feed.discover,
{
context,
pageSize: 12,
liveUpdates: true,
onEvolve: async (delta) => {
// Persist the interaction to YOUR own API. SemiLayer never owns the like.
await fetch('/api/interactions', {
method: 'POST',
body: JSON.stringify(delta.meta),
})
},
}
)
// Card click handler — the customer names the interaction.
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}>Load more</button>}
</div>
)
}Design discipline
The hook never names a specific interaction — no like(), follow(), etc.
Your app supplies meta ({ kind: 'positive' } / { kind: 'follow' } /
whatever your product calls it). Your onEvolve callback persists however you
want. We provide the lifecycle; you keep the vocabulary.
When the user has many signals
Past ~50-100 signals (likes, views, follows), embedding the average produces a "you like everything" vector that's useless for ranking. Trim at your data layer — three patterns scale cleanly:
- Recency window:
SELECT title FROM likes WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50— pass those 50 to context. - Pre-computed taste vector: server-side, average the embeddings of recent likes once, cache as
number[], pass directly. Skips on-demand embedding entirely. recordVectormode: for "more like this" feeds, the seed is a single clicked record. Zero scaling concern.
Full discussion: Signals — your data, your control.
Live tail — useSubscribe + useObserve
// Stream every change to a lens
const events = useSubscribe('orders', { filter: { status: 'pending' } })
// Watch one record's state
const order = useObserve('orders', orderId)Analyze — useAnalyze + useAnalyzeRows + useAnalyzeList
The hook trio for the analyze facet. Takes a
codegen-emitted analyze handle directly. Owns the lifecycle: first-page fetch
on mount, refetch on input change, optional live analyze.subscribe, applies
analyze.diff frames in place, exposes a subtle evolved UI cue when the top
buckets reorder.
import { useAnalyze, useAnalyzeRows } from '@semilayer/react'
import { beam } from './generated/beam'
export function PublishedByDayTile() {
const { result, loading, evolved, refetch } = useAnalyze(
beam.recipes.analyze.publishedByDay,
{ input: { where: { status: 'published' } }, liveUpdates: true },
)
if (loading || !result) return <Spinner />
return (
<>
{evolved && <button onClick={refetch}>✨ ranking shifted — refresh</button>}
<ul>
{result.buckets.map((b) => (
<li key={b.bucketKey}>{String(b.dims.day)} — {String(b.measures.count)}</li>
))}
</ul>
</>
)
}
export function DrillDownPanel({ bucketKey }: { bucketKey: string | null }) {
const { rows, fetchMore, cursor } = useAnalyzeRows(
beam.recipes.analyze.publishedByDay,
{ bucketKey },
)
return (
<div>
{rows.map((r) => <Row key={String(r.id)} {...r} />)}
{cursor && <button onClick={fetchMore}>Load more</button>}
</div>
)
}For the full chart-rendering surface (SVG line / area / pie / heatmap / funnel /
cohort / treemap / radar) see @semilayer/charts
and the React wrapper @semilayer/react-charts.
Headless chart-shape utilities
Pure functions for re-shaping AnalyzeResult into chart-library-friendly
formats (Recharts, ECharts, Plotly, D3, …) — re-exported from
@semilayer/headless:
import { toLineSeries, toFunnelSteps } from '@semilayer/react/headless'
const series = toLineSeries(result, { xField: 'day', yField: 'count', seriesField: 'cuisine' })Streaming pagination — useStreamSearch + useStreamQuery
// Yield rows as they arrive instead of buffering
const { rows, done } = useStreamSearch('articles', { query: 'cache' })Returns
| Hook | Returns |
|---|---|
| useSearch / useSimilar / useQuery | { data, loading, error, refetch } |
| useStreamSearch / useStreamQuery | { rows, done, error, cancel } |
| useSubscribe | { events, error, cancel } |
| useObserve | { record, error, cancel } |
| useFeed | { items, fetchMore, refetch, evolve, liveCount, evolved, isLoading, error, cursor } |
| useAnalyze | { result, loading, error, refetch, evolve, evolved, lastUpdatedAt, plan } |
| useAnalyzeRows | { rows, fetchMore, total, loading, error, cursor, crossSource } |
| useAnalyzeList | { analyses, loading, error, refetch } |
See Also
- Full hook reference
- Feed facet overview
@semilayer/client— the underlying runtime + 5 built-inFeedCacheadapters
License
MIT
