Skip to content

Repository files navigation

Retree

A reactive state tree library for .NET. Retree automatically detects field changes on your objects and propagates change events up the tree hierarchy — giving you fine-grained observability without manual event wiring.

Retree is a C# port of the TypeScript library Retree.

Features

  • Automatic change detection — Fields on your nodes are polled each tick and compared to snapshots using compiled expression delegates for performance.
  • Tree propagation — Changes on any descendant node bubble up to ancestors that have registered tree listeners.
  • Observable collectionsRetreeList<T> and RetreeDictionary<TKey, TValue> fire change events synchronously on mutation.
  • Parent tracking — Nodes automatically know their parent in the tree.
  • Transactions — Batch multiple mutations into a single event emission, or suppress events entirely with silent mode.
  • Zero dependencies — Targets netstandard2.0 with no external packages. Works in Unity, server apps, or anywhere .NET runs.

Project Structure

RetreeDotnet/
├── src/RetreeCore/                    # Core library (netstandard2.0)
├── src/RetreeUnity/                   # Unity package (com.ryanbliss.retreecore)
│   ├── Runtime/                       # RetreeUpdater + precompiled DLL
├── samples/SpaceInvaders/             # Reference Unity project (Space Invaders game)
├── tests/RetreeCore.Tests/            # NUnit test suite (net9.0)
└── benchmarks/RetreeCore.Benchmarks/  # Performance benchmarks (net9.0)

Installation

Unity (via Package Manager)

Requires Unity 6000.0+.

Open Window → Package Manager → + → Add package from git URL and enter:

https://github.com/ryanbliss/RetreeDotnet.git?path=src/RetreeUnity

This installs the com.ryanbliss.retreecore package, which includes the precompiled RetreeCore.dll and the RetreeUpdater MonoBehaviour for main-thread ticking. No additional dependencies are required.

To pin a specific version, append #<tag-or-commit>:

https://github.com/ryanbliss/RetreeDotnet.git?path=src/RetreeUnity#v0.1.0

Or add it directly to your Packages/manifest.json:

{
  "dependencies": {
    "com.ryanbliss.retreecore": "https://github.com/ryanbliss/RetreeDotnet.git?path=src/RetreeUnity"
  }
}

.NET (non-Unity)

Reference the RetreeCore project directly or build the DLL:

dotnet build src/RetreeCore/RetreeCore.csproj -c Release

The output DLL is at src/RetreeCore/bin/Release/netstandard2.0/RetreeCore.dll.

Prerequisites

  • .NET 9.0 SDK (for tests and benchmarks)
  • Any runtime supporting .NET Standard 2.1 (for the core library)

Building

Build the entire solution:

dotnet build RetreeCore.sln

Build only the core library:

dotnet build src/RetreeCore/RetreeCore.csproj

Running Tests

dotnet test tests/RetreeCore.Tests/RetreeCore.Tests.csproj

Running Benchmarks

dotnet run --project benchmarks/RetreeCore.Benchmarks/RetreeCore.Benchmarks.csproj -c Release

Available CLI options:

Option Values Default
--size small, medium, large, xlarge, all all
--ops low, medium, high, xhigh, all all
--iterations Any positive integer 100
--warmup Any positive integer 5
--verbose Flag Off

Example:

dotnet run --project benchmarks/RetreeCore.Benchmarks/RetreeCore.Benchmarks.csproj -c Release -- --size medium --ops low --iterations 50

Quick Start

Define Your Nodes

Extend RetreeNode and declare fields. Retree observes instance fields — not properties, not readonly, not static. Use [RetreeIgnore] to exclude a field.

using RetreeCore;

public class Todo : RetreeNode
{
    [RetreeIgnore]
    public string id;
    public string text = "";
    public bool isComplete = false;

    public Todo(string id)
    {
        this.id = id;
    }

    public void Toggle()
    {
        isComplete = !isComplete;
    }
}

public class TodoList : RetreeNode
{
    public RetreeList<Todo> todos = new();

    public void Add(string text)
    {
        var todo = new Todo(Guid.NewGuid().ToString()) { text = text };
        todos.Add(todo);
    }

    public void Remove(Todo todo)
    {
        todos.Remove(todo);
    }
}

Listen for Changes

var list = new TodoList();

// Tree listener — fires when any descendant changes
list.OnTreeChanged(args =>
{
    Console.WriteLine($"Tree changed! Source: {args.SourceNode.GetType().Name}");
    foreach (var change in args.Changes)
    {
        Console.WriteLine($"  {change.FieldName}: {change.OldValue} -> {change.NewValue}");
    }
});

// Add a todo (synchronous — fires immediately since RetreeList detects mutations at call time)
list.Add("Buy groceries");
// Output:
//   Tree changed! Source: RetreeList`1
//   Items: (null) -> Todo

// Mutate a field (polling — requires a tick to detect)
list.todos[0].Toggle();
Retree.Tick();
// Output:
//   Tree changed! Source: Todo
//   isComplete: False -> True

Node-Level Listening

For changes only on a specific node's own fields (not its descendants):

var todo = list.todos[0];
todo.OnNodeChanged(args =>
{
    Console.WriteLine($"Todo changed:");
    foreach (var change in args.Changes)
    {
        Console.WriteLine($"  {change.FieldName}: {change.OldValue} -> {change.NewValue}");
    }
});

todo.text = "Buy milk instead";
Retree.Tick();
// Output:
//   Todo changed:
//   text: Buy groceries -> Buy milk instead

Removing a Todo

Use Retree.Parent() to navigate the tree:

public class Todo : RetreeNode
{
    // ... fields ...

    public void Delete()
    {
        var parent = Retree.Parent(this);
        if (parent is RetreeList<Todo> list)
        {
            list.Remove(this);
        }
    }
}

Transactions

Batch multiple changes into a single event:

Retree.RunTransaction(() =>
{
    list.Add("Task A");
    list.Add("Task B");
    list.Add("Task C");
    // All three additions emit as one batched event
});

Silent Mode

Apply changes without firing any events:

Retree.RunSilent(() =>
{
    list.todos[0].text = "Updated silently";
    Retree.Tick();
    // No events fire, but the snapshot is updated
});

Auto-Ticking

Instead of calling Retree.Tick() manually, start automatic polling:

// Tick every 50ms
Retree.StartTicks(0.05f);

// Later, stop ticking
Retree.StopTicks();

Cleanup

// Unregister specific listeners
list.OffTreeChanged(myHandler);
todo.OffNodeChanged(myHandler);

// Or clear all listeners on a node (optionally recursive)
Retree.ClearListeners(list, recursive: true);

API Reference

RetreeNode

Abstract base class for observable nodes. Extend this to define your state objects.

Observed fields: Instance, non-readonly, non-static fields of value types, string, RetreeNode, RetreeList<T>, or RetreeDictionary<TKey, TValue>. Exclude fields with [RetreeIgnore].

RetreeList<T> where T : RetreeNode

Observable list. Implements IList<T> and IReadOnlyList<T>. Mutations fire NodeChanged events synchronously with FieldName = "Items".

RetreeDictionary<TKey, TValue> where TValue : RetreeNode

Observable dictionary. Implements IDictionary<TKey, TValue> and IReadOnlyDictionary<TKey, TValue>. Mutations fire NodeChanged events synchronously with FieldName = key.ToString().

Retree (Static API)

Method Description
Retree.Tick() Poll all active nodes for field changes.
Retree.StartTicks(float rate) Begin auto-ticking at the given interval in seconds.
Retree.StopTicks() Stop auto-ticking.
Retree.IsTicking Whether auto-ticking is active.
Retree.Parent(RetreeBase node) Get a node's parent in the tree.
Retree.RunTransaction(Action action) Batch events during action into a single emission.
Retree.RunSilent(Action action) Suppress all events during action.
Retree.ClearListeners(RetreeBase node, bool recursive) Remove all listeners from a node.

Event Args

Type Properties
NodeChangedArgs Node, Changes (list of FieldChange)
TreeChangedArgs ListenerNode, SourceNode, Changes (list of FieldChange)
FieldChange FieldName, OldValue, NewValue

Licensing & Copyright

Copyright (c) Ryan Bliss. All rights reserved. Licensed under MIT license.

See LICENSE for details.

About

Simple reactive state tree for .NET

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages