Skip to content
Contents

TanStack DB

Sketch. Nothing on this page is built yet. It is here for review.

TanStack DB keeps collections of rows in the browser and runs live queries over them. Rindle can fill those collections: each collection is one of your query endpoints, kept live by Rindle.

Install:

npm i @rindle/tanstack-db @tanstack/react-db

A collection

Point a collection at a query endpoint, the same endpoints useLiveQuery uses (see Architecture):

import { createCollection } from "@tanstack/react-db";
import { createReactiveSqlClient } from "@rindle/reactive-sql-client";
import { rindleCollectionOptions } from "@rindle/tanstack-db";

const db = await createReactiveSqlClient({ stream: "/api/rindle-connect" });

type Note = { id: number; title: string; owner: string };

export const notes = createCollection(
  rindleCollectionOptions<Note>({
    db,
    query: "/api/notes",
    getKey: (note) => note.id,
  }),
);

The collection is ready when the query’s first rows arrive. After that, every write that changes the result updates the collection.

Query it with TanStack DB as usual:

import { useLiveQuery } from "@tanstack/react-db";

export function Notes() {
  const { data } = useLiveQuery((q) => q.from({ note: notes }).orderBy(({ note }) => note.id, "desc"));
  return <ul>{data.map((n) => <li key={n.id}>{n.title}</li>)}</ul>;
}

Writes

Writes go to your own endpoints. Return Rindle’s write receipt from the endpoint, and pass it to awaitWrite. TanStack DB shows the change immediately and keeps it until Rindle’s stream includes it:

export const notes = createCollection(
  rindleCollectionOptions<Note>({
    db,
    query: "/api/notes",
    getKey: (note) => note.id,
    onUpdate: async ({ transaction, collection }) => {
      for (const { modified } of transaction.mutations) {
        const res = await fetch(`/api/notes/${modified.id}`, {
          method: "POST",
          body: JSON.stringify({ title: modified.title }),
        });
        await collection.utils.awaitWrite(await res.json());
      }
    },
  }),
);

How loading works

TanStack DB implements joins and LIMIT in a way that causes waterfalls. Rindle can do better, but the APIs in TanStack DB need to be changed to take advantage of this capability.

Rindle keeps a whole query live on the server: joins, EXISTS, and ORDER BY … LIMIT, including refilling the window when rows leave it. The browser receives the result on one stream.

TanStack DB works per collection. When it loads data on demand:

  • Joins are a waterfall. It loads one side of a join, computes the join keys in the browser, then requests the matching rows of the other side. Each level of join is another round trip.
  • LIMIT refills are requests. When rows leave a limited window, it asks for the next page with a cursor or offset.

So a TanStack DB query that joins or limits across collections makes several requests, even though Rindle could have answered it in one.

You can avoid this by doing the join or limit in the endpoint’s SQL instead. A Rindle collection loads its whole query up front, so TanStack DB never asks it for more rows: Rindle keeps the joined, limited result live on the server and refills the window itself. For example, an endpoint that returns the newest 50 notes, each with its comments as an array:

SELECT * FROM note ORDER BY id DESC LIMIT 50
  WITH comments OF comment ON note_id = id (ORDER BY id)

The cost is flexibility: the join and the limit are fixed by the endpoint. A TanStack DB query over that collection can still filter and sort the rows it holds, but a join or limit written in TanStack DB across two collections goes back to loading per collection.

Full queries on the server

It should be possible to change TanStack DB to work more like Zero: send the whole query to the server and receive its rows for every collection it touches, with no waterfall. Rindle already streams results this way. This hasn’t been investigated deeply.