npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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@latest

Or install directly with npm:

npm i @retreejs/core @retreejs/convex convex

Install with yarn:

yarn add @retreejs/core @retreejs/convex convex

Feature glossary

  • 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.
  • ConvexAuthStateNode stores the Convex client's authentication state (isLoading / isAuthenticated) in Retree state — the useConvexAuth equivalent.
  • 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.

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 cleanup

Args 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 optimisticUpdate

Paginated 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.