Docs
Convex integration
Own a Convex client from a ReactiveNode — live queries written into Retree state, reconciled by _id, with narrow optimistic updates and automatic disposal.
@retreejs/convex connects Convex to Retree state. A
ConvexNode owns a Convex client and
creates typed helpers: this.query(...) for live query subscriptions,
this.queryOnce(...) for one-off reads, this.mutation(...) and
this.action(...) for writes, plus paginated queries and connection state.
Query results are written into Retree state, so everything you know about
useNode, useSelect, and ReactiveNode applies unchanged. Convex document
arrays are reconciled by _id by default, and optimistic updates are applied
narrowly to existing query state.
Under the hood, the Convex nodes are adapters over the backend-agnostic
QueryNode from @retreejs/query — the status machine, args
lifecycle, and optimistic machinery documented there apply here verbatim.
Install#
For non-React apps (or state that doesn't share Convex React's client):
npm i @retreejs/core @retreejs/convex convexFor React apps that want one client instance for both Convex React and Retree (covered below):
npm i @retreejs/core @retreejs/react @retreejs/convex @retreejs/react-convex convexA state node that owns a client#
Extend ConvexNode, pass it a Convex client, and build query nodes with the
protected this.query(...) helper. This is the README's TasksState example:
import { ConvexNode, ConvexQueryNode } from "@retreejs/convex";
import { ConvexClient } from "convex/browser";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
class TasksState extends ConvexNode {
public readonly tasks: ConvexQueryNode<typeof api.tasks.get>;
constructor(convexUrl: string) {
const client = new ConvexClient(convexUrl);
super(client);
this.tasks = this.query(api.tasks.get);
}
get dependencies() {
return [];
}
public toggleCompleted(taskId: Id<"tasks">): Promise<null> {
const toggleCompleted = this.mutation(api.tasks.toggleCompleted);
return toggleCompleted(
{ taskId },
{
withOptimisticUpdate: (ctx) => {
this.tasks.optimisticUpdate({
ctx,
apply(tasks) {
const task = tasks.find((candidateTask) => {
return candidateTask._id === taskId;
});
if (!task) return;
task.isCompleted = !task.isCompleted;
},
});
},
}
);
}
}Two classes to know:
ConvexNode— the full base class:this.query(...),this.paginatedQuery(...),this.connectionState(),this.mutation(...),this.action(...),this.queryOnce(...).BaseConvexNode— the smaller base class when a node only needsthis.mutation(...),this.action(...), orthis.queryOnce(...)— a form state node that submits a mutation, for example.
Both are ReactiveNode subclasses, so everything from
View models — dependencies, @select, memoization,
lifecycle hooks — composes with them.
Query state and status#
ConvexQueryNode is itself a
ReactiveNode. When Convex sends a new value, the query node writes its
state, result, and error fields, which emits Retree listeners and
re-renders subscribed components:
state— the convenient query value (undefineduntil loaded, or yourinitialState).result— a status union:{ status: "pending" },{ status: "skipped" },{ status: "success", data }, or{ status: "error", error }.error— the last error, if any.
import { useSelect } from "@retreejs/react";
function TaskCount({
tasks,
}: {
tasks: ConvexQueryNode<typeof api.tasks.get>;
}) {
const count = useSelect(tasks, (node) => node.state.length);
return <span>{count}</span>;
}Pass initialState when you want a defined value before the first server
result (this.query(api.tasks.get, { initialState: [] })). Pass "skip" to
this.query(...) or updateArgs(...) to disable the subscription:
tasks.updateArgs({ projectId: "p1" }); // ✅ changes args and resubscribes
tasks.updateArgs({ projectId: "p1" }); // ❌ deep-equal args — no resubscribe
tasks.updateArgs("skip"); // ✅ emits skipped state and unsubscribesArgs are compared deeply (matching Convex's own structural comparison), so passing a fresh-but-equal args object causes no churn.
Keep data visible while args change#
By default a resubscribe resets the node to pending. Construct the query
with keepPreviousData to keep the previous state visible while the new
subscription loads — result stays success with isStale: true until the
first value arrives:
this.tasks = this.query(api.tasks.byProject, {
args: { projectId },
keepPreviousData: true,
});
this.tasks.updateArgs({ projectId: nextId });
// ✅ state keeps the old rows; result is { status: "success", data, isStale: true }Recover from errors with retry()#
After result.status === "error", call
retry() to close the errored
subscription and open a fresh one with the current args. It does nothing in
any other status, so it wires straight to a button:
{state.tasks.result.status === "error" ? (
<button onClick={() => state.tasks.retry()}>Retry</button>
) : null}The repository's sample 04 shows this branch in a working app.
Mutations, actions, and one-off queries#
this.mutation(...), this.action(...), and this.queryOnce(...) are typed
imperative helpers. They do not emit through Retree by themselves — they
only trigger updates when your code writes their results into Retree state, or
when a mutation is paired with an optimistic update:
const generateSummary = this.action(api.ai.generateSummary);
const summary = await generateSummary({ taskId });
const task = await this.queryOnce(api.tasks.getById, { taskId });(Outside a node class,
createRetreeConvexMutation
and
createRetreeConvexAction
create the same typed helpers from a client instance.)
Optimistic updates, applied narrowly#
ConvexQueryNode.optimisticUpdate(...) takes a transform that mutates the
existing query state in place — as in toggleCompleted above, which flips one
field on one task. Because the update is a normal Retree mutation on existing
nodes, only the components subscribed to the changed task re-render; the rest
of the list is untouched.
Rollback semantics: if the mutation promise rejects before a changed server
value arrives, the query node restores the latest clean server baseline as of
rejection time — overlapping mutations are generation-tracked, so an older
mutation's failure never wipes a newer confirmed one. If Convex sends a
changed value first, the dirty optimistic state is cleared and a later
rejection is ignored. You can provide a custom revert(...) when you need
different rollback behavior.
React apps: RetreeConvexReactClient#
React apps that already use Convex React should create one
RetreeConvexReactClient
from @retreejs/react-convex. It extends Convex's ConvexReactClient and
adds the subscription surface Retree Convex nodes need — so the same instance
works in ConvexProvider and in your ConvexNode constructors:
"use client";
import { useNode, useRoot } from "@retreejs/react";
import { ConvexNode, ConvexQueryNode } from "@retreejs/convex";
import { RetreeConvexReactClient } from "@retreejs/react-convex";
import { api } from "../convex/_generated/api";
const convexClient = new RetreeConvexReactClient(
process.env.NEXT_PUBLIC_CONVEX_URL!
);
class TasksState extends ConvexNode {
public readonly tasks: ConvexQueryNode<typeof api.tasks.get>;
constructor() {
super(convexClient);
this.tasks = this.query(api.tasks.get, { initialState: [] });
}
get dependencies() {
return [];
}
}
export function TaskList() {
const root = useRoot(() => new TasksState());
const state = useNode(root);
return (
<ul>
{state.tasks.state?.map((task) => {
return <li key={task._id}>{task.text}</li>;
})}
</ul>
);
}useRoot creates the state for the component's lifetime; useNode(root)
subscribes to it. Rendering state.tasks.state works because the query node
writes into Retree state like any other node.
Disposal is automatic in React#
When TaskList unmounts, useNode(root) releases its Retree observer.
Query, paginated query, and connection-state nodes clean themselves up when
they lose their last active observer — no manual cleanup effect needed — and
resubscribe when observed again. Disposal is sticky in between: writes to a
disposed node do not silently reopen its Convex subscription. Calling
dispose() yourself is still useful for non-React app shutdown.
Server-side rendering (Next.js preload)#
preloadedQueryOptions from @retreejs/react-convex is the Retree
equivalent of Convex React's usePreloadedQuery: a server component runs
preloadQuery from convex/nextjs, and the client derives args and
initialState for a ConvexQueryNode — the first render shows server data
with result.status === "success" (no pending flash), then the node switches
to live values when the websocket subscription emits:
// app/tasks/page.tsx (server component)
const preloaded = await preloadQuery(api.tasks.list, { listId });
return <TasksClient preloaded={preloaded} />;// TasksClient.tsx ("use client")
import { preloadedQueryOptions } from "@retreejs/react-convex";
const root = useRoot(
() =>
new ConvexQueryNode(convexClient, api.tasks.list, {
...preloadedQueryOptions(preloaded),
})
);Inside a ConvexNode, spread the same options into
this.query(api.tasks.list, { ...preloadedQueryOptions(preloaded) }). The
payload's args become the node's initial args, so the live subscription runs
the exact query the server preloaded.
Reactive auth state#
ConvexAuthStateNode is the
Retree equivalent of Convex React's useConvexAuth. Convex clients only
surface auth changes through setAuth callbacks, so the node needs a client
that makes auth observable — RetreeConvexReactClient does, by interposing
on setAuth/clearAuth:
import { ConvexAuthStateNode } from "@retreejs/convex";
const auth = Retree.root(new ConvexAuthStateNode(convexClient));
function AuthGate({ children }: { children: React.ReactNode }) {
const state = useNode(auth);
if (state.isLoading) return <Spinner />;
if (!state.isAuthenticated) return <SignIn />;
return children;
}Wire your auth provider as usual (convexClient.setAuth(fetchToken));
isLoading / isAuthenticated flow into Retree automatically while the node
is observed.
Reconciliation#
Convex document arrays are reconciled by _id by default: when a new server
result arrives, unchanged documents keep stable object identity. That is what
keeps Retree's child-node rendering pattern working for server lists — a
useNode(task) row only re-renders when its document changed:
function TaskRow({ task }: { task: Doc<"tasks"> }) {
const taskNode = useNode(task);
return <span>{taskNode.text}</span>;
}For non-Convex arrays with a different id field, use
reconcileArrayById:
this.tasks = this.query(api.tasks.listByProject, {
args: { projectId },
reconcile: reconcileArrayById("id"),
});Custom reconcilers#
A custom IStateReconciler
implements reconcile(current, next, rawCurrent). The third argument is the
proxy-free raw view of current (Retree.raw). Reconciliation is
read-dominated, so read from rawCurrent, write to current: comparisons
run at native speed, and writes through current emit nodeChanged for
changed rows while keeping item identity stable. Writing to rawCurrent
skips emission — never do it.
const reconcileTasks: IStateReconciler<Task[]> = {
reconcile(current, next, rawCurrent) {
if (current === undefined) return next;
for (let index = 0; index < next.length; index++) {
if (rawCurrent?.[index]?.text !== next[index]!.text) {
current[index]!.text = next[index]!.text; // ✅ emits
}
}
current.length = next.length;
return current;
},
};The built-in reconcilers
(reconcileConvexDocuments,
reconcileArrayById) already read raw and write through current internally.
Paginated queries#
Use this.paginatedQuery(...) for Convex paginated queries.
ConvexPaginatedQueryNode
exposes the aggregate paginated state plus a loadMore(...) helper:
this.messages = this.paginatedQuery(api.messages.list, {
args: { channelId },
initialNumItems: 20,
});
const didRequestMore = this.messages.loadMore(20);loadMore(numItems) requests another page and returns false when there is
no active subscription to extend. New pages update the node and emit through
Retree. Like query nodes, paginated nodes have state, a result status
union (pending / skipped / success / error, with the success data
carrying results across all loaded pages and the current pagination
status), error, updateArgs(...), retry(), and dispose().
Loaded rows are reconciled by _id when any page updates or loadMore
lands, so row nodes keep identity and useNode(row) subscriptions stay
narrow. Optimistic updates work on paginated nodes too — the transform
mutates the loaded state.results rows in place, and rollback restores the
loaded rows and pagination status while keeping the loadMore function by
reference:
const send = this.mutation(api.messages.send);
return send(
{ channelId, body },
{
withOptimisticUpdate: (ctx) => {
this.messages.optimisticUpdate({
ctx,
apply(page) {
page.results.unshift(localMessage);
},
});
},
}
);Connection state#
Use this.connectionState() to create a
ConvexConnectionStateNode
that mirrors the Convex client's connection state into Retree:
this.connection = this.connectionState();import { useSelect } from "@retreejs/react";
function ConnectionBadge({ state }: { state: ConvexConnectionStateNode }) {
const status = useSelect(state, (node) => node.state);
return <span>{status.hasInflightRequests ? "Syncing" : "Idle"}</span>;
}A complete, running example#
The repository ships a full Next.js + Convex sample —
samples/04.convex-react-nextjs
— with a TasksState node, per-row optimistic updates, filters built on
@select, and the RetreeConvexReactClient setup. Its state layer
(app/tasks-state.ts) uses decorators, so it also doubles as a working
reference for Setup & decorators.
Where next#
- Async queries — the backend-agnostic
QueryNodemachinery the Convex nodes share. - View models — the
ReactiveNodemachinery Convex nodes are built on. - Events & subscriptions —
Retree.onand the event types, for integrating anything that isn't Convex. - Performance — reconciliation and raw reads in the wider cost model.