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

From Redux Toolkit

A concept-by-concept map from Redux Toolkit to Retree — slices, reducers, dispatch, and useSelector — with a before/after slice and the gotchas.

Here's the friendly irony of this migration: the "mutating" code you write in createSlice reducers is already Retree code — Redux Toolkit runs it through Immer to produce immutable updates, while Retree just runs it. Moving over mostly means deleting the machinery around that code: actions, dispatch, the store configuration, and the selector layer. This page is the routing table, not a tutorial; the Quickstart and Thinking in Retree cover the destination properly.

Concept map#

Redux ToolkitRetree
configureStoreRetree.root — one call, no reducer map
createSliceA child node per domain — a plain object or class under the root
Reducers (Immer-style mutation)Methods that mutate this — the same code, running for real
dispatch(action)Call the method
useSelectoruseSelect — or useNode on the node a component owns
useDispatchNot needed
createSelector (reselect)@memo getters on a ReactiveNode
createAsyncThunkPlain async methods that mutate state before/after await
<Provider store={store}>Not needed
RTK Query@retreejs/convex for Convex; otherwise fetch inside async methods — no general data-fetching layer today
Redux DevTools@retreejs/devtools — connectReduxDevTools(), with JSON-state time travel
MiddlewareNone — cross-cutting reactions subscribe with Retree.on

Before and after#

A tasks slice with a reducer, a thunk, and a selector:

Redux Toolkit
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

export const loadTasks = createAsyncThunk("tasks/load", () => fetchTasks());

const tasksSlice = createSlice({
    name: "tasks",
    initialState: { items: [] as Task[], loading: false },
    reducers: {
        toggled(state, action: { payload: number }) {
            const task = state.items.find((t) => t.id === action.payload);
            if (task) task.done = !task.done;
        },
    },
    extraReducers: (builder) => {
        builder
            .addCase(loadTasks.pending, (state) => {
                state.loading = true;
            })
            .addCase(loadTasks.fulfilled, (state, action) => {
                state.items = action.payload;
                state.loading = false;
            });
    },
});

// In a component:
const doneCount = useSelector(
    (state: RootState) => state.tasks.items.filter((task) => task.done).length
);
dispatch(tasksSlice.actions.toggled(taskId));
Retree
import { Retree } from "@retreejs/core";
import { useSelect } from "@retreejs/react";

class TasksSlice {
    items: Task[] = [];
    loading = false;

    toggled(id: number) {
        const task = this.items.find((t) => t.id === id);
        if (task) task.done = !task.done; // the reducer body, unwrapped
    }

    async load() {
        this.loading = true;
        this.items = await fetchTasks();
        this.loading = false;
    }
}

const store = Retree.root({ tasks: new TasksSlice() });

// In a component:
const doneCount = useSelect(
    () => store.tasks.items.filter((task) => task.done).length
);
store.tasks.toggled(taskId);

The reducer body survived verbatim; the action type, the dispatch, the thunk lifecycle cases, and the provider did not.

Gotchas#

  • There is no enforced action pipeline. Redux's contract — every change is a serializable action through one dispatcher — is what buys its middleware chain. Retree deliberately trades that for direct mutation: any code holding a node can write. You still get an attributable, replayable record of every write (change records, replayable with Retree.applyChanges) and time-travel debugging via @retreejs/devtools — what you give up is the single funnel that middleware hangs off.
  • State is mutable for real. Any code holding a node can write to it, and the write emits — there is no reducer boundary to funnel changes through. Convention (methods on the owning class) replaces enforcement.
  • Re-renders come from subscriptions, not root immutability. useSelector re-runs on every store update and compares results; a Retree component subscribes to the nodes it reads and is simply not notified for the rest. Give each rendered child node its own useNode.
  • Normalization is optional. Without immutable updates, deep nesting stops being expensive — an update three levels down is one write, not three spreads. Keep entity maps where they model the domain well; drop the ones that only existed to keep reducers shallow. Retree enforces one structural parent per node, with link for cross-references.
  • Batch multi-step updates. Each write emits individually where a reducer emitted once per action. Wrap multi-write updates in Retree.runTransaction when subscribers should see them as one change.

Where next#

  • Thinking in Retree — the model replacing the store/action/reducer triangle.
  • View models — where reselect-style derived state lives.
  • Testing — asserting on state and emission without a dispatch pipeline.
← PreviousFrom Zustand

On this page

  • Concept map
  • Before and after
  • Gotchas
  • Where next