vybekiit 0.7.33 → 0.7.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,407 @@
1
+ <!-- Generated by scripts/sync-skill-docs.mjs from website@thruflo/realtime-3-integration:f26c1f629 (docs/realtime/guides/reactivity). Do not edit: change the docs and run npm run sync:docs. sha256:f6b3f6d6112f189444e30239f81d46c9e1431a61c2242286807d01190137ab87 -->
2
+ Canonical page: https://neon.com/docs/realtime/guides/reactivity.md
3
+
4
+ > Summary: Bind live queries to React component state with useLiveQuery, change their parameters and handle errors, or sync a broader live query into a TanStack DB collection and derive instant, local queries from it.
5
+
6
+ # Client-side reactivity
7
+
8
+ Bind live queries to component and agent state.
9
+
10
+ ## Contents
11
+
12
+ - [React](#react)
13
+ - [RealtimeProvider](#realtimeprovider)
14
+ - [useLiveQuery](#uselivequery)
15
+ - [Changing query parameters](#changing-query-parameters)
16
+ - [Handling errors](#handling-errors)
17
+ - [TanStack DB](#tanstack-db)
18
+ - [Seal a broader query](#seal-a-broader-query)
19
+ - [Create the collection](#create-the-collection)
20
+ - [Query the collection](#query-the-collection)
21
+ - [Manage optimistic state](#manage-optimistic-state)
22
+ - [Other frameworks](#other-frameworks)
23
+
24
+ Build reactive apps and agents using higher-level reactivity primitives that manage live query [subscriptions](subscribing.md) for you.
25
+
26
+ **Important: Framework support**
27
+
28
+ Realtime [currently](#other-frameworks) has integrations for [React](#react) and [TanStack DB](#tanstack-db).
29
+
30
+ The [React binding](#react) keeps a component's state in step with a live query, and the [TanStack DB binding](#tanstack-db) syncs live queries into a [reactive client-side store](https://tanstack.com/db).
31
+
32
+ This page continues the [running example](https://github.com/neondatabase/examples/tree/main/realtime/guides-chat-app) from the [queries](queries.md) and [subscribing](subscribing.md) guides. It uses the `client` from `frontend/client.ts` and `fetchSealedMessages()` from `frontend/api.ts`.
33
+
34
+ ## React
35
+
36
+ The `@neon/realtime-react` binding exports a [`RealtimeProvider`](https://neon.com/docs/realtime/sdks/typescript.md#react-realtimeprovider), a [`useLiveQuery()` hook](https://neon.com/docs/realtime/sdks/typescript.md#react-uselivequery) and a [`useRealtimeClient()` hook](https://neon.com/docs/realtime/sdks/typescript.md#react-userealtimeclient) that returns the provider's client. It works with React 18 and 19.
37
+
38
+ Install it alongside the client SDK:
39
+
40
+ ```bash filename="Terminal"
41
+ npm install @neon/realtime-react
42
+ ```
43
+
44
+ ### RealtimeProvider
45
+
46
+ Wrap your app in `<RealtimeProvider>`. This allows all of your `useLiveQuery()` subscriptions to use the same [`RealtimeClient`](https://neon.com/docs/realtime/sdks/typescript.md#client-realtimeclient) and WebSocket connection.
47
+
48
+ ```tsx filename="frontend/main.tsx"
49
+ import { StrictMode } from "react"
50
+ import { createRoot } from "react-dom/client"
51
+ import { RealtimeProvider } from "@neon/realtime-react"
52
+
53
+ import { client } from "./client"
54
+
55
+ createRoot(document.getElementById("root")!).render(
56
+ <StrictMode>
57
+ <RealtimeProvider client={client}>
58
+ {/* Your app here */}
59
+ </RealtimeProvider>
60
+ </StrictMode>
61
+ )
62
+ ```
63
+
64
+ `client` is the module singleton from `frontend/client.ts`, created once for the whole app. If you render on the server, create the client in the browser instead, as the [server-side rendering guide](ssr.md#open-the-connection-in-the-browser) explains.
65
+
66
+ ### useLiveQuery
67
+
68
+ [`useLiveQuery()`](https://neon.com/docs/realtime/sdks/typescript.md#react-uselivequery) takes a `sealedQuery` and a `refreshQuery` (and, optionally, [`initialData` from SSR](ssr.md)) and returns the query's current rows and state.
69
+
70
+ For example, here we define a `Messages` component with `useLiveQuery`:
71
+
72
+ ```tsx filename="frontend/Messages.tsx"
73
+ import { useEffect } from "react"
74
+ import { useLiveQuery } from "@neon/realtime-react"
75
+ import type { SealedLiveQuery } from "@neon/realtime/client"
76
+
77
+ import type { Message } from "../backend/schema"
78
+ import { LocalTime } from "./LocalTime"
79
+
80
+ interface Props {
81
+ sealedQuery: SealedLiveQuery<Message>;
82
+ refreshQuery: () => Promise<SealedLiveQuery<Message>>;
83
+ initialData?: readonly Message[];
84
+ }
85
+
86
+ export function Messages({ sealedQuery, refreshQuery, initialData }: Props) {
87
+ const { data, status, error, utils } = useLiveQuery(sealedQuery, { refreshQuery, initialData })
88
+
89
+ useEffect(() => {
90
+ return utils.onUpdate((update) => {
91
+ if (update.type === "changes") {
92
+ console.debug(`Applied ${update.changes.length} changes from transactions ${update.batch.txids.join(", ")}`)
93
+ }
94
+ })
95
+ }, [utils])
96
+
97
+ if (status === "error") {
98
+ return <p role="alert">Live updates stopped: {error.message}</p>
99
+ }
100
+
101
+ if (data === undefined) {
102
+ return <p>Loading messages</p>
103
+ }
104
+
105
+ return (
106
+ <section>
107
+ {status === "stale" && <p role="status">Updating</p>}
108
+ <ul>
109
+ {data.map((message) => (
110
+ <li key={message.id}>
111
+ <LocalTime date={message.created_at} />
112
+ <p>{message.body}</p>
113
+ </li>
114
+ ))}
115
+ </ul>
116
+ </section>
117
+ )
118
+ }
119
+ ```
120
+
121
+ Which uses this `LocalTime` component to format the time [after hydration](ssr.md#keep-row-shapes-identical):
122
+
123
+ ```tsx filename="frontend/LocalTime.tsx"
124
+ import { useEffect, useState } from "react"
125
+
126
+ export function LocalTime({ date }: { date: Date }) {
127
+ const [formatted, setFormatted] = useState<string>()
128
+ useEffect(() => setFormatted(date.toLocaleTimeString()), [date])
129
+
130
+ return <time dateTime={date.toISOString()}>{formatted}</time>
131
+ }
132
+ ```
133
+
134
+ `useLiveQuery` subscribes when the component mounts, re-renders it whenever the results change and unsubscribes when it unmounts.
135
+
136
+ In this example, the sealed query is a `SealedLiveQuery<Message>`, so `data` is a `readonly Message[]`, with `created_at` decoded to a `Date`. It's `undefined` until the first result arrives. `status` is the subscription's [state](subscribing.md#listen-for-changes): the component shows a loading line until the first result and a small indicator while it's `stale`.
137
+
138
+ #### refreshQuery
139
+
140
+ `refreshQuery` is how the `useLiveQuery` hook [renews the sealed query](subscribing.md#renew-the-sealed-query).
141
+
142
+ Sealed queries are [short-lived](queries.md#renewing-sealed-queries), so the hook calls it 10 seconds before `expiresAt`. If a call fails, the hook retries every second until one succeeds. The callback has to return a sealed query for the same exact query, so `Channel` builds it from the same `channelId` the sealed query was sealed with, as [changing query parameters](#changing-query-parameters) shows. Changing only the callback doesn't restart the subscription, and `useCallback` keeps it stable across renders. Without `refreshQuery`, nothing renews the sealed query, and the rows go `stale` when it expires.
143
+
144
+ #### initialData
145
+
146
+ The hook also accepts an optional `initialData` parameter, for example from a server render. See the [server-side rendering guide](ssr.md#render-the-initial-rows-straight-away) for more details.
147
+
148
+ #### utils
149
+
150
+ The `utils` in the return value give you access to the hook's underlying [materialized subscription](https://neon.com/docs/realtime/sdks/typescript.md#react-uselivequeryutils).
151
+
152
+ This provides `getSnapshot()`, `getState()`, `awaitRows()`, the `onChange`, `onUpdate` and `onStateChange` listeners, `renew()` and `awaitTxId()`. It's stable for the life of the subscription, so register listeners in an effect and return the remover, as the `onUpdate` listener does.
153
+
154
+ Note that a `changes` update has already been applied by the time its listener runs, and it [carries the txids](https://neon.com/docs/realtime/sdks/typescript.md#client-livequerybatchinfo) of the transactions in it, which the [mutations guide](mutations.md#how-optimistic-mutations-work) uses to confirm optimistic writes.
155
+
156
+ `utils` omits `unsubscribe()`, because React owns cleanup. The hook unsubscribes when the component unmounts or its sealed query changes. `getState()` and the listeners report the state of the underlying subscription, so they can differ from the hook's `status`.
157
+
158
+ ### Changing query parameters
159
+
160
+ The hook takes a sealed query, not parameters. Parameters are sealed into the query on the server, where the client can't change them. To show a different channel, fetch a new sealed query for it and pass that in. A new `sealedQuery` is a new logical subscription: the hook cleans up the old one and subscribes to the new one.
161
+
162
+ Fetch the sealed query in the component that owns the parameter. `useSealedQuery()` fetches one whenever `channelId` changes, and `Channel` renders `Messages` once it has one:
163
+
164
+ ```tsx filename="frontend/Channel.tsx"
165
+ import { useCallback, useEffect, useState } from "react"
166
+ import type { SealedLiveQuery } from "@neon/realtime/client"
167
+
168
+ import type { Message } from "../backend/schema"
169
+ import { fetchSealedMessages } from "./api"
170
+ import { Messages } from "./Messages"
171
+
172
+ interface SealedChannel {
173
+ channelId: number;
174
+ sealedQuery?: SealedLiveQuery<Message>;
175
+ error?: unknown;
176
+ }
177
+
178
+ function useSealedQuery(channelId: number): SealedChannel | undefined {
179
+ const [sealed, setSealed] = useState<SealedChannel>()
180
+
181
+ useEffect(() => {
182
+ let cancelled = false
183
+
184
+ fetchSealedMessages(channelId).then(
185
+ (sealedQuery) => {
186
+ if (!cancelled) setSealed({ channelId, sealedQuery })
187
+ },
188
+ (error: unknown) => {
189
+ if (!cancelled) setSealed({ channelId, error })
190
+ }
191
+ )
192
+
193
+ return () => {
194
+ cancelled = true
195
+ }
196
+ }, [channelId])
197
+
198
+ // Never hand the previous channel's sealed query to the new channel
199
+ return sealed?.channelId === channelId ? sealed : undefined
200
+ }
201
+
202
+ export function Channel({ channelId }: { channelId: number }) {
203
+ const sealed = useSealedQuery(channelId)
204
+ // Renewals must be for the same query, so refresh with the same channel
205
+ const refreshQuery = useCallback(() => fetchSealedMessages(channelId), [channelId])
206
+
207
+ if (sealed?.sealedQuery) {
208
+ return <Messages sealedQuery={sealed.sealedQuery} refreshQuery={refreshQuery} />
209
+ }
210
+
211
+ if (sealed?.error) {
212
+ return <p role="alert">Couldn't open this channel</p>
213
+ }
214
+
215
+ return <p>Loading messages</p>
216
+ }
217
+ ```
218
+
219
+ When the channel changes, `sealed` still holds the previous channel's sealed query until the new one arrives. Without the `channelId` check, `Messages` would get the old channel's sealed query with a `refreshQuery` for the new channel. That `refreshQuery` would then fetch the new channel's query to renew the old subscription, and the renewal would be rejected with status `error`. The `cancelled` flag covers a second race. It drops a response that arrives after the channel has changed, or after Strict Mode's extra effect run in development.
220
+
221
+ ### Handling errors
222
+
223
+ `useLiveQuery()` doesn't throw for subscription errors, and it doesn't suspend. It reports everything through `status`. Render loading and error states from it, as `Messages` does. The hook throws only when it's used outside a `<RealtimeProvider>`, when the sealed query is malformed or when the client is closed. Those errors reach the nearest error boundary, not `status`.
224
+
225
+ A failed renewal doesn't end in `error`. If your endpoint can't return a sealed query, for example because the user has signed out and it answers 401, the hook keeps retrying, and once the sealed query expires the status is `stale` until a renewal succeeds. So drive sign-out from your auth state: when the user signs out, unmount the components that subscribe. The status becomes `error` only when a renewal returns a malformed response or a sealed query for a different query. To recover, fetch a new sealed query and pass it in.
226
+
227
+ As with a parameter change, the new sealed query restarts the subscription. The [subscribing guide](subscribing.md#handle-errors) covers recovery at the subscription level.
228
+
229
+ ## TanStack DB
230
+
231
+ Each `useLiveQuery()` above subscribes to one sealed query, so each view of the data needs its own sealed query and its own round trip to your server.
232
+
233
+ With [TanStack DB](https://tanstack.com/db/latest/docs/overview), you can oversync a broader live query into a client-side collection, such as every message in every channel the user belongs to, and then derive as many local live queries from it as you need: [filters, sorts and joins across collections](https://tanstack.com/db/latest/docs/guides/live-queries).
234
+
235
+ Install the TanStack binding and TanStack DB's React package:
236
+
237
+ ```bash filename="Terminal"
238
+ npm install @neon/realtime-tanstack @tanstack/react-db
239
+ ```
240
+
241
+ ### Seal a broader query
242
+
243
+ Add `inboxQuery()` to `backend/queries.ts`. It's `messagesQuery()`, with the same membership join but no channel filter. So it returns the newest 500 messages across every channel that the user belongs to:
244
+
245
+ ```ts filename="backend/queries.ts"
246
+ export function inboxQuery(userId: string) {
247
+ return db
248
+ .select(getTableColumns(messages))
249
+ .from(messages)
250
+ .innerJoin(channelMembers, eq(channelMembers.channel_id, messages.channel_id))
251
+ .where(eq(channelMembers.user_id, userId))
252
+ .orderBy(desc(messages.created_at))
253
+ .limit(500)
254
+ }
255
+ ```
256
+
257
+ Then expose it. In `backend/server.ts`, import `inboxQuery` alongside `messagesQuery` and chain one more route onto the app. It takes no body, because its only parameter comes from the verified token:
258
+
259
+ ```ts filename="backend/server.ts"
260
+ .post("/api/v1/inbox/live", async (c) => {
261
+ const user = await requireUser(c)
262
+
263
+ const sealedQuery = await realtime.seal({ query: inboxQuery(user.id) })
264
+
265
+ return c.json(sealedQuery)
266
+ })
267
+ ```
268
+
269
+ ### Create the collection
270
+
271
+ On the frontend, add `fetchSealedInbox()` to `frontend/api.ts`, next to `fetchSealedMessages()`. As before, the `as` restores the row type:
272
+
273
+ ```ts filename="frontend/api.ts"
274
+ export async function fetchSealedInbox(): Promise<SealedLiveQuery<Message>> {
275
+ const res = await server.api.v1.inbox.live.$post()
276
+
277
+ if (!res.ok) {
278
+ throw new Error(`Could not fetch the inbox query (${res.status})`)
279
+ }
280
+
281
+ return (await res.json()) as SealedLiveQuery<Message>
282
+ }
283
+ ```
284
+
285
+ Then create the collection. `realtimeCollectionOptions()` takes the standard TanStack DB collection options plus the Realtime client and a sealed query, and returns options for `createCollection()`:
286
+
287
+ ```ts filename="frontend/collections.ts"
288
+ import { createCollection } from "@tanstack/react-db"
289
+ import { realtimeCollectionOptions } from "@neon/realtime-tanstack"
290
+ import type { SealedLiveQuery } from "@neon/realtime/client"
291
+
292
+ import type { Message } from "../backend/schema"
293
+ import { fetchSealedInbox } from "./api"
294
+ import { client } from "./client"
295
+
296
+ export function createMessagesCollection(sealedQuery: SealedLiveQuery<Message>) {
297
+ return createCollection(
298
+ realtimeCollectionOptions({
299
+ id: "messages",
300
+ client,
301
+ query: sealedQuery,
302
+ refreshQuery: fetchSealedInbox,
303
+ getKey: (message) => message.id
304
+ })
305
+ )
306
+ }
307
+
308
+ export type MessagesCollection = ReturnType<typeof createMessagesCollection>
309
+ ```
310
+
311
+ `getKey` returns each message's primary key. TanStack DB keys rows by it, so use a stable, unique column, never a subscription's `rowId`. `refreshQuery` renews the sealed query the same way [the React hook does](#refreshquery). While renewals fail, the collection stays `ready` with its last rows. See the [`realtimeCollectionOptions()` reference](https://neon.com/docs/realtime/sdks/typescript.md#tanstack-db-realtimecollectionoptions) for the rest of the options.
312
+
313
+ The collection starts syncing as soon as a live query using it is mounted. It's in the `loading` state until the first result arrives, and `ready` from then on, with each committed transaction applied atomically. A `ready` collection stays ready while the subscription is `connecting` or `stale`, so there's no separate recovery flag to render. If the subscription ends in `error`, so does the collection.
314
+
315
+ ### Query the collection
316
+
317
+ Create the collection once, when the app starts. The collection takes the client directly, but `Channel` still calls `useLiveQuery()`, so keep both views inside `RealtimeProvider`:
318
+
319
+ ```tsx filename="frontend/main.tsx"
320
+ import { StrictMode } from "react"
321
+ import { createRoot } from "react-dom/client"
322
+ import { RealtimeProvider } from "@neon/realtime-react"
323
+
324
+ import { fetchSealedInbox } from "./api"
325
+ import { authClient } from "./auth"
326
+ import { Channel } from "./Channel"
327
+ import { client } from "./client"
328
+ import { createMessagesCollection } from "./collections"
329
+ import { Inbox } from "./Inbox"
330
+
331
+ const root = createRoot(document.getElementById("root")!)
332
+ const { data: session } = await authClient.getSession()
333
+
334
+ if (!session) {
335
+ root.render(<p>Sign in to see the messages.</p>)
336
+ } else {
337
+ const messagesCollection = createMessagesCollection(await fetchSealedInbox())
338
+
339
+ root.render(
340
+ <StrictMode>
341
+ <RealtimeProvider client={client}>
342
+ <Channel channelId={2} />
343
+ <Inbox messagesCollection={messagesCollection} channelId={2} />
344
+ </RealtimeProvider>
345
+ </StrictMode>
346
+ )
347
+ }
348
+ ```
349
+
350
+ Sign-in isn't part of these guides: build the page with Neon's [Managed Better Auth UI components](https://neon.com/docs/auth/reference/ui-components.md), as the [running example](https://github.com/neondatabase/examples/tree/main/realtime/guides-chat-app) does by rendering `AuthView` on `/auth/*` before it checks the session.
351
+
352
+ Then read from the collection with TanStack's own `useLiveQuery()`. It shares a name with the Realtime hook, so import it under an alias that won't clash in an app that uses both. This view shows one channel, newest first:
353
+
354
+ ```tsx filename="frontend/Inbox.tsx"
355
+ import { eq, useLiveQuery as useCollectionQuery } from "@tanstack/react-db"
356
+
357
+ import type { MessagesCollection } from "./collections"
358
+
359
+ interface Props {
360
+ messagesCollection: MessagesCollection;
361
+ channelId: number;
362
+ }
363
+
364
+ export function Inbox({ messagesCollection, channelId }: Props) {
365
+ const { data, isReady, isError } = useCollectionQuery({
366
+ query: (q) =>
367
+ q
368
+ .from({ message: messagesCollection })
369
+ .where(({ message }) => eq(message.channel_id, channelId))
370
+ .orderBy(({ message }) => message.created_at, "desc")
371
+ })
372
+
373
+ if (isError) {
374
+ return <p role="alert">Couldn't sync messages</p>
375
+ }
376
+
377
+ if (!isReady) {
378
+ return <p>Loading messages</p>
379
+ }
380
+
381
+ return (
382
+ <ul>
383
+ {data.map((message) => (
384
+ <li key={message.id}>{message.body}</li>
385
+ ))}
386
+ </ul>
387
+ )
388
+ }
389
+ ```
390
+
391
+ Pass a different `channelId` and the query re-runs in the browser, against the rows the collection already holds. Local queries only see what the collection holds, so a channel whose messages fall outside the newest 500 shows fewer rows, or none. There's no new sealed query and no round trip to your server, unlike [changing the parameters](#changing-query-parameters) of a `useLiveQuery()` hook. Every view you add, like an unread count or a search across channels, is another local query over the same synced rows.
392
+
393
+ For server rendering, the [server-side rendering guide](ssr.md#with-tanstack-db) seeds a collection on the server and hydrates it in the browser, and the [TanStack example](https://neon.com/docs/realtime/examples/tanstack.md) builds a full app with TanStack Start.
394
+
395
+ ### Manage optimistic state
396
+
397
+ Once you have a TanStack DB collection defined, you can also use it for local optimistic writes. It's often easier in a complex app to normalize data loading into a collection which can have optimistic state applied to it once, than to have to remember to apply optimistic state to a range of different live query subscriptions.
398
+
399
+ For writes through the collection, with optimistic state, see the [mutations guide](mutations.md#with-tanstack-db-collections).
400
+
401
+ ## Other frameworks
402
+
403
+ Bindings for other frameworks are coming. Until then, you can use the current implementations as a reference to write your own.
404
+
405
+ The React binding is a thin layer over the client subscription: `useSyncExternalStore` plus the [`QueryRefreshController`](https://neon.com/docs/realtime/sdks/typescript.md#client-queryrefreshcontroller). Point a coding agent at [its source](https://github.com/neondatabase/neon-pkgs/tree/main/packages/realtime-react) and it can reproduce the binding for other frameworks, such as Solid, Svelte or Vue.
406
+
407
+ You can also see the [source code for the TanStack DB collection here](https://github.com/neondatabase/neon-pkgs/blob/main/packages/realtime-tanstack/src/collection-options.ts).