retree
DocsAPIWhy Retree

Start here

  • Quickstart
  • Thinking in Retree
  • Common pitfalls

React

  • Choosing a hook
  • useRoot
  • useNode
  • useTree
  • useSelect
  • useRaw

Core

  • Events & subscriptions
  • Effects & reactions
  • Tree operations
  • Transactions & silent writes
  • Undo & redo

View models

  • ReactiveNode & decorators
  • Setup & decorators

Going deeper

  • Select semantics
  • Performance
  • React Compiler
  • Testing
  • DevTools
  • Convex integration
  • Async queries
  • Compatibility

Migrate

  • From MobX
  • From Zustand
  • From Redux Toolkit

Start here

  • Quickstart
  • Thinking in Retree
  • Common pitfalls

React

  • Choosing a hook
  • useRoot
  • useNode
  • useTree
  • useSelect
  • useRaw

Core

  • Events & subscriptions
  • Effects & reactions
  • Tree operations
  • Transactions & silent writes
  • Undo & redo

View models

  • ReactiveNode & decorators
  • Setup & decorators

Going deeper

  • Select semantics
  • Performance
  • React Compiler
  • Testing
  • DevTools
  • Convex integration
  • Async queries
  • Compatibility

Migrate

  • From MobX
  • From Zustand
  • From Redux Toolkit
Loading page…

retree

Reactive object trees for React. MIT licensed.

© 2026 Ryan Bliss

Docs

  • Quickstart
  • Thinking in Retree
  • React hooks
  • Common pitfalls

Reference

  • @retreejs/core
  • @retreejs/query
  • @retreejs/react
  • @retreejs/devtools
  • @retreejs/convex
  • @retreejs/react-convex

Project

  • Why Retree
  • GitHub
  • npm
  • llms.txt

Docs

Edit on GitHub

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 convex

For 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 convex

A 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:

tasks-state.ts
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 needs this.mutation(...), this.action(...), or this.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 (undefined until loaded, or your initialState).
  • 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 unsubscribes

Args 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 QueryNode machinery the Convex nodes share.
  • View models — the ReactiveNode machinery Convex nodes are built on.
  • Events & subscriptions — Retree.on and the event types, for integrating anything that isn't Convex.
  • Performance — reconciliation and raw reads in the wider cost model.
← PreviousDevToolsNext →Async queries

On this page

  • Install
  • A state node that owns a client
  • Query state and status
  • Keep data visible while args change
  • Recover from errors with retry()
  • Mutations, actions, and one-off queries
  • Optimistic updates, applied narrowly
  • React apps: RetreeConvexReactClient
  • Disposal is automatic in React
  • Server-side rendering (Next.js preload)
  • Reactive auth state
  • Reconciliation
  • Custom reconcilers
  • Paginated queries
  • Connection state
  • A complete, running example
  • Where next