@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.
Install with npm:
npm i @retreejs/core @retreejs/convex convex
Install with yarn:
yarn add @retreejs/core @retreejs/convex convex
ConvexNode is the full base class for app state that owns a Convex client. Use it when you want this.query(...), this.paginatedQuery(...), this.connectionState(...), this.mutation(...), this.action(...), and this.queryOnce(...).BaseConvexNode is the smaller base class for nodes that only need this.mutation(...), this.action(...), or this.queryOnce(...).ConvexQueryNode stores one live Convex query in Retree state. Use it for subscribed query results that should trigger Retree/React updates.ConvexPaginatedQueryNode stores one live paginated Convex query and exposes loadMore(...).ConvexConnectionStateNode stores the Convex client's connection state in Retree state.createRetreeConvexMutation and createRetreeConvexAction create typed imperative helpers when you are not inside a BaseConvexNode.optimisticUpdate mutates a query node optimistically and emits through Retree immediately.reconcileConvexDocuments and reconcileArrayById preserve item identity across server results so child useNode(item) components stay narrow.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>;
}
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("skip"); // ✅ emits skipped state and unsubscribes
tasks.dispose(); // ✅ stops the subscription; call during app cleanup
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.
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 optimisticUpdate
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.
const didRequestMore = this.messages.loadMore(20);
this.messages.dispose();
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>;
}
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 last clean server value. 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.
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"),
});
Docs are hosted at https://ryanbliss.github.io/retree/.
Copyright (c) Ryan Bliss. All rights reserved. Licensed under MIT license.