@retreejs/convex
v0.8.0
Published
Sync Convex queries into Retree reactive nodes.
Downloads
1,923
Readme
Retree Convex
@retreejs/convex connects Convex query subscriptions to Retree ReactiveNode state. It lets a Retree node own a Convex client, create typed query nodes with this.query(...), run one-off queries with this.queryOnce(...), call actions and mutations, and keep optimistic updates close to the query state they affect.
The Convex nodes are adapters over the backend-agnostic QueryNode from @retreejs/query — the status machine, args lifecycle, and optimistic machinery are shared.
How to install
The quickest way is the interactive installer — it detects React and Convex and installs the matching Retree packages:
npm create @retreejs@latestOr install directly with npm:
npm i @retreejs/core @retreejs/convex convexInstall with yarn:
yarn add @retreejs/core @retreejs/convex convexFeature glossary
ConvexNodeis the full base class for app state that owns a Convex client. Use it when you wantthis.query(...),this.paginatedQuery(...),this.connectionState(...),this.mutation(...),this.action(...), andthis.queryOnce(...).BaseConvexNodeis the smaller base class for nodes that only needthis.mutation(...),this.action(...), orthis.queryOnce(...).ConvexQueryNodestores one live Convex query in Retree state. Use it for subscribed query results that should trigger Retree/React updates.ConvexPaginatedQueryNodestores one live paginated Convex query and exposesloadMore(...).ConvexConnectionStateNodestores the Convex client's connection state in Retree state.ConvexAuthStateNodestores the Convex client's authentication state (isLoading/isAuthenticated) in Retree state — theuseConvexAuthequivalent.createRetreeConvexMutationandcreateRetreeConvexActioncreate typed imperative helpers when you are not inside aBaseConvexNode.optimisticUpdatemutates a query node optimistically and emits through Retree immediately.reconcileConvexDocumentsandreconcileArrayByIdpreserve item identity across server results so childuseNode(item)components stay narrow.
How to use
Create an app state node that extends ConvexNode, pass it a Convex client, and build query nodes with the protected this.query(...) helper. Query arguments are optional for Convex queries that do not require args.
ConvexNode extends BaseConvexNode. Use BaseConvexNode directly when a node only needs the protected this.action(...), this.mutation(...), and this.queryOnce(...) helpers and does not need query-node factories.
import { ConvexNode, ConvexQueryNode } from "@retreejs/convex";
import { ConvexClient } from "convex/browser";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
export 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 dispose(): void {
this.tasks.dispose();
void this.client.close();
}
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) => candidateTask._id === taskId
);
if (!task) return;
task.isCompleted = !task.isCompleted;
},
});
},
}
);
}
}You can also construct a query node directly:
const tasks = new ConvexQueryNode(client, api.tasks.get);
const filteredTasks = new ConvexQueryNode(client, api.tasks.byStatus, {
args: { isCompleted: false },
});Query nodes are ReactiveNodes. When Convex sends a new value, the query node writes state, result, and error, which emits Retree listeners and re-renders React components subscribed with useNode, useTree, or useSelect.
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>;
}Query status and skipping
ConvexQueryNode.state keeps the convenient query value, while ConvexQueryNode.result exposes a status union for loading, success, skipped, and error states:
const tasks = this.query(api.tasks.byProject, {
args: { projectId },
});
if (tasks.result.status === "error") {
console.error(tasks.result.error);
}Pass "skip" to the constructor, this.query(...), or updateArgs(...) to disable a subscription:
this.tasks.updateArgs(projectId ? { projectId } : "skip");tasks.updateArgs({ projectId: "p1" }); // ✅ changes args and resubscribes
tasks.updateArgs({ projectId: "p1" }); // ❌ deep-equal args — no resubscribe
tasks.updateArgs("skip"); // ✅ emits skipped state and unsubscribes
tasks.dispose(); // ✅ stops the subscription; call during app cleanupArgs are compared deeply (matching Convex's own structural comparison), so passing a fresh-but-equal args object causes no churn. Disposal is sticky: writes to a disposed node do not silently reopen the subscription; the node resubscribes when it gains a new Retree observer.
Keep previous data while args change
By default a resubscribe resets the node to pending. Pass 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 }Retry after an error
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:
{
tasks.result.status === "error" ? (
<button onClick={() => tasks.retry()}>Retry</button>
) : null;
}Actions and one-off queries
Use this.action(...) for Convex actions and this.queryOnce(...) when you need an imperative query result without subscribing:
const generateSummary = this.action(api.ai.generateSummary);
const summary = await generateSummary({ taskId });
const task = await this.queryOnce(api.tasks.getById, { taskId });These helpers do not emit by themselves. They only trigger Retree updates if your code writes their result into a Retree node or uses a mutation optimistic update.
Standalone action and mutation helpers
Use createRetreeConvexAction(...) and createRetreeConvexMutation(...) when you want typed helpers without subclassing BaseConvexNode.
import {
createRetreeConvexAction,
createRetreeConvexMutation,
} from "@retreejs/convex";
const generateSummary = createRetreeConvexAction(
client,
api.ai.generateSummary
);
const toggleCompleted = createRetreeConvexMutation(
client,
api.tasks.toggleCompleted
);
await generateSummary({ taskId }); // ❌ no Retree emit by itself
await toggleCompleted({ taskId }); // ❌ no Retree emit unless paired with optimisticUpdatePaginated queries
Use this.paginatedQuery(...) for Convex paginated queries. The node exposes the aggregate paginated state and a loadMore(...) helper:
this.messages = this.paginatedQuery(api.messages.list, {
args: { channelId },
initialNumItems: 20,
});
this.messages.loadMore(20);loadMore(...) requests another page and returns false when there is no active subscription to extend. New pages update the paginated query node and emit through Retree. Loaded rows are reconciled by _id when any page updates or loadMore lands, so row nodes keep identity and useNode(row) subscriptions stay narrow.
const didRequestMore = this.messages.loadMore(20);
this.messages.dispose();Paginated nodes support optimisticUpdate(...) 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:
this.messages.optimisticUpdate({
ctx,
apply(page) {
page.results.unshift(localMessage);
},
});Connection state
Use this.connectionState() to create a node that tracks the Convex client's connection state:
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>;
}Reactive auth state
ConvexAuthStateNode tracks a Convex client's authentication state — the Retree equivalent of Convex React's useConvexAuth. Convex clients only surface auth changes through setAuth callbacks, so the node needs a client implementing Retree's observable auth surface (IConvexAuthClient); RetreeConvexReactClient from @retreejs/react-convex implements it by interposing on setAuth/clearAuth:
import { Retree } from "@retreejs/core";
import { ConvexAuthStateNode } from "@retreejs/convex";
const auth = Retree.root(new ConvexAuthStateNode(convexClient));
Retree.on(auth, "nodeChanged", () => {
console.log(auth.isLoading, auth.isAuthenticated);
});
// Wire your auth provider as usual; state flows automatically:
convexClient.setAuth(fetchToken);isLoading is true while a token change awaits server confirmation; isAuthenticated flips once the server validates the credentials. The node subscribes while observed and cleans up when it loses its last observer.
Server-side rendering (Next.js preload)
For Next.js RSC hydration — server-fetched data on first render, live values once the websocket emits — use preloadedQueryOptions(...) from @retreejs/react-convex to derive args and initialState for a ConvexQueryNode from a preloadQuery payload.
Optimistic updates
ConvexQueryNode.optimisticUpdate(...) accepts a narrow transform and an optional mutation context. Call it without ctx for local optimistic state that should stay dirty until Convex sends a changed server value. Pass ctx when you also want mutation failure to roll back the dirty state:
const toggleCompleted = this.mutation(api.tasks.toggleCompleted);
return toggleCompleted(
{ taskId },
{
withOptimisticUpdate: (ctx) => {
this.tasks.optimisticUpdate({
ctx,
apply(tasks) {
const task = tasks.find((candidate) => {
return candidate._id === taskId;
});
if (!task) return;
task.isCompleted = !task.isCompleted;
},
});
},
}
);If the mutation promise rejects before a changed server value arrives, ConvexQueryNode 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 later mutation rejection is ignored. Server echoes that match the last clean value keep the optimistic state in place. You can provide revert(...) when you need custom rollback behavior.
Reconciliation
Convex document arrays are reconciled by _id by default, so unchanged documents keep stable object identity when new query results arrive. This keeps Retree child-node rendering patterns useful for lists:
function TaskRow({ task }: { task: Doc<"tasks"> }) {
const taskNode = useNode(task);
return <span>{taskNode.text}</span>;
}For non-Convex arrays, use reconcileArrayById(...):
this.tasks = this.query(api.tasks.listByProject, {
args: { projectId },
reconcile: reconcileArrayById("id"),
});Custom reconcilers receive a third rawCurrent argument — 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.
Docs
Docs are hosted at https://www.retree.dev — see the Convex integration guide.
Licensing & Copyright
Copyright (c) Ryan Bliss. All rights reserved. Licensed under MIT license.
