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

Testing

Unit-test Retree state and ReactiveNodes with vitest, test components with Testing Library, and fake the Convex client for query-node tests.

Retree state is plain objects, so most tests need no special setup: create a root, mutate it, assert. The examples use vitest and Testing Library — the same stack Retree's own test suite uses — but nothing here is runner-specific.

Unit-test plain state#

Create the root inside the test so every test starts from a known tree. Assert with plain reads, and assert emission by subscribing with Retree.on and a mock:

project.spec.ts
import { describe, expect, it, vi } from "vitest";
import { Retree } from "@retreejs/core";

describe("project", () => {
    it("emits nodeChanged for own-field writes", () => {
        const project = Retree.root({
            title: "Launch checklist",
            tasks: [{ text: "Write docs", done: false }],
        });
        const nodeChanged = vi.fn();
        Retree.on(project, "nodeChanged", nodeChanged);

        project.title = "Launch checklist v2";

        expect(project.title).toBe("Launch checklist v2");
        expect(nodeChanged).toHaveBeenCalledTimes(1);
    });

    it("keeps deep writes off the root's nodeChanged", () => {
        const project = Retree.root({
            title: "Launch checklist",
            tasks: [{ text: "Write docs", done: false }],
        });
        const nodeChanged = vi.fn();
        const treeChanged = vi.fn();
        Retree.on(project, "nodeChanged", nodeChanged);
        Retree.on(project, "treeChanged", treeChanged);

        project.tasks[0].done = true;

        expect(nodeChanged).not.toHaveBeenCalled(); // ❌ two levels down
        expect(treeChanged).toHaveBeenCalledTimes(1); // ✅ ancestors see it
    });
});

A root created per test is garbage-collected with the test — no global store to reset. When state is module-scoped (a shared app tree imported by the code under test), clear its listeners between tests so one test's subscriptions can't leak into the next:

import { afterEach } from "vitest";
import { Retree } from "@retreejs/core";
import { appState } from "./app-state";

afterEach(() => {
    // `false` clears listeners on every descendant node too.
    Retree.clearListeners(appState, false);
});

React apps can skip the boilerplate: @retreejs/react/testing ships createTestRoot, which pairs a fresh root with a cleanup that runs the deep listener clear for you:

import { afterEach, it } from "vitest";
import { createTestRoot } from "@retreejs/react/testing";

const { root, cleanup } = createTestRoot(() => new TaskBoard());
afterEach(cleanup);

Unit-test ReactiveNodes#

ReactiveNode classes test like any class — instantiate, call methods, assert getters. Pass the instance to Retree.root when the behavior under test involves reactivity, like a @select getter deciding whether the owner emits:

task-board.spec.ts
import { expect, it, vi } from "vitest";
import { Retree, ReactiveNode, select } from "@retreejs/core";

class TaskBoard extends ReactiveNode {
    public tasks: { text: string; done: boolean }[] = [];

    @select
    get doneCount() {
        return this.tasks.filter((task) => task.done).length;
    }

    get dependencies() {
        return [];
    }
}

it("emits only when the done count changes", () => {
    const board = Retree.root(new TaskBoard());
    board.tasks.push({ text: "Write docs", done: false });

    const nodeChanged = vi.fn();
    Retree.on(board, "nodeChanged", nodeChanged);

    board.tasks[0].done = true; // ✅ doneCount 0 -> 1
    expect(nodeChanged).toHaveBeenCalledTimes(1);

    board.tasks[0].text = "Better docs"; // ❌ selection unchanged
    expect(nodeChanged).toHaveBeenCalledTimes(1);
});

The same shape works for dependencies and lifecycle hooks: mutate the dependency, assert the owner emitted (or didn't).

Test components with Testing Library#

Components using Retree hooks render and test like any other component. Interactions fired through Testing Library (fireEvent, userEvent) are already wrapped in act, so event-handler mutations just work. When the test mutates the tree directly — simulating a change from outside the component — wrap the write in act so React flushes the update before you assert. @retreejs/react/testing exports actOnRetree(write) as a typed shortcut for exactly that (sync and async overloads); the examples below use React's act directly so the mechanics stay visible:

TaskRow.spec.tsx
import React from "react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { expect, it } from "vitest";
import { Retree } from "@retreejs/core";
import { useNode } from "@retreejs/react";

function TaskRow({ task }: { task: { text: string; done: boolean } }) {
    const state = useNode(task);
    return (
        <label>
            <input
                type="checkbox"
                checked={state.done}
                onChange={() => (state.done = !state.done)}
            />
            {state.text}
        </label>
    );
}

it("re-renders when the task changes", () => {
    const project = Retree.root({
        tasks: [{ text: "Write docs", done: false }],
    });
    render(<TaskRow task={project.tasks[0]} />);

    // ✅ fireEvent is act-wrapped — the handler's mutation flushes
    fireEvent.click(screen.getByRole("checkbox"));
    expect(screen.getByRole<HTMLInputElement>("checkbox").checked).toBe(true);

    // ✅ direct external writes need act()
    act(() => {
        project.tasks[0].text = "Ship docs";
    });
    expect(screen.getByText("Ship docs")).toBeTruthy();
});

For async flows (a method that fetches, then mutates), await waitFor(...) on the expected UI, exactly as you would without Retree.

Fake the Convex client#

Every Convex node takes its client through the IConvexClient interface, so tests can substitute a fake and drive "server" emissions by hand — no network, no Convex deployment. The fake captures each query subscription; calling a captured callback plays the role of the server pushing a result:

fake-convex-client.ts
import { vi } from "vitest";
import type { ConnectionState } from "convex/browser";
import type {
    FunctionArgs,
    FunctionReference,
    FunctionReturnType,
} from "convex/server";
import type { IConvexClient } from "@retreejs/convex";

const connectedState: ConnectionState = {
    hasInflightRequests: false,
    isWebSocketConnected: true,
    timeOfOldestInflightRequest: null,
    hasEverConnected: true,
    connectionCount: 1,
    connectionRetries: 0,
    inflightMutations: 0,
    inflightActions: 0,
};

export class FakeConvexClient implements IConvexClient {
    /** One entry per query subscription, in subscribe order. */
    public subscriptions: {
        args: unknown;
        callback: (result: never) => unknown;
        onError: ((error: Error) => unknown) | undefined;
        unsubscribe: ReturnType<typeof vi.fn>;
    }[] = [];
    /** Every mutation the code under test ran. */
    public mutationCalls: { mutation: unknown; args: unknown }[] = [];
    /** Set this before acting to control the next mutation's outcome. */
    public nextMutationPromise: Promise<unknown> = Promise.resolve(null);

    onUpdate<Query extends FunctionReference<"query">>(
        _query: Query,
        args: FunctionArgs<Query>,
        callback: (result: FunctionReturnType<Query>) => unknown,
        onError?: (error: Error) => unknown
    ) {
        const unsubscribe = vi.fn();
        this.subscriptions.push({ args, callback, onError, unsubscribe });
        return Object.assign(unsubscribe, {
            unsubscribe,
            getCurrentValue: () => undefined,
        });
    }

    mutation<Mutation extends FunctionReference<"mutation">>(
        mutation: Mutation,
        args: FunctionArgs<Mutation>
    ): Promise<Awaited<FunctionReturnType<Mutation>>> {
        this.mutationCalls.push({ mutation, args });
        return this.nextMutationPromise as Promise<
            Awaited<FunctionReturnType<Mutation>>
        >;
    }

    // The rest of the surface, stubbed. Grow these into recording fakes
    // (like `mutation` above) when a test needs them.
    action(): Promise<never> {
        throw new Error("FakeConvexClient: action() is not implemented.");
    }

    query(): Promise<never> {
        throw new Error("FakeConvexClient: query() is not implemented.");
    }

    onPaginatedUpdate_experimental(): never {
        throw new Error(
            "FakeConvexClient: paginated queries are not implemented."
        );
    }

    connectionState(): ConnectionState {
        return connectedState;
    }

    subscribeToConnectionState(): () => void {
        return vi.fn();
    }

    close(): Promise<void> {
        return Promise.resolve();
    }
}

With the fake in place, a query-node test reads like a conversation with the server:

tasks-state.spec.ts
import { expect, it, vi } from "vitest";
import { Retree } from "@retreejs/core";
import { ConvexQueryNode } from "@retreejs/convex";
import { api } from "../convex/_generated/api";
import { FakeConvexClient } from "./fake-convex-client";

it("writes server emissions into state", () => {
    const client = new FakeConvexClient();
    const node = Retree.root(
        new ConvexQueryNode(client, api.tasks.get, { initialState: [] })
    );
    const nodeChanged = vi.fn();
    Retree.on(node, "nodeChanged", nodeChanged);

    // Play the server: push a result into the captured subscription.
    client.subscriptions[0].callback([
        { _id: "task-1", text: "Buy groceries", isCompleted: false },
    ] as never);

    expect(node.state).toEqual([
        { _id: "task-1", text: "Buy groceries", isCompleted: false },
    ]);
    expect(nodeChanged).toHaveBeenCalled();
});

Assert outgoing traffic through client.mutationCalls, and drive failure paths by pointing nextMutationPromise at a rejection or calling a captured onError. Retree's own packages/retree-convex/src/ConvexQueryNode.spec.ts uses exactly this pattern, with the fake grown to cover actions, pagination, and connection state — a good reference when a test outgrows the minimal version.

Where next#

  • Events & subscriptions — Retree.on, payloads, and clearListeners in depth.
  • Convex integration — the nodes the fake client plugs into.
  • Common pitfalls — failure modes worth a regression test of their own.
← PreviousReact CompilerNext →DevTools

On this page

  • Unit-test plain state
  • Unit-test ReactiveNodes
  • Test components with Testing Library
  • Fake the Convex client
  • Where next