Skip to content

3. Reactivity

Updated: 2026-07-01 - added props between components viaSetChildProp/ BindChildProp.

Prop<​T> - reactive property

csharp
public class HealthBar : SusComponent
{
    public Prop<​float> Health = new(100f);
    public Prop<​string> Name = new("Player");

    protected override void Created()
    {
        Watch(Health, (oldVal, newVal) =>
        {
            Debug.Log($"Health: {oldVal} → {newVal}");
        });
    }

    private void TakeDamage(float amount)
    {
        Health.Value -= amount;  // UI will update automatically
    }
}

Features:

  • Implicit cast:Prop<​float> Howfloat(through implicit operator)
  • Comparison by value: if new == old, eventChanged not called
  • IL2CPP-safe: does not use reflection

Computed<​T> - calculated property

csharp
public class Inventory : SusComponent
{
    public Prop<​int> Gold = new(100);
    public Prop<​int> Gems = new(50);

    public Computed<​int> TotalValue => C(() => Gold.Value + Gems.Value * 10);

    protected override void Build()
    {
        var label = new Label();
        BindText(label, () => TotalValue.ToString());
    }
}

Computed<​T> caches the value and recalculates it only when dependencies change. Auto tracking: whenValue calculates_fn(), All Prop<​T>.ValueAnd Computed<​T>.Value read inside automatically become dependencies.

From July 1, 2026:Computed<​T> implementsIReactiveSource— is itself a reactive source:

  • ChainsProp → Computed A → Computed B → BindText work (push invalidation up the chain)
  • BindText(label, () => MyComputed.Value)— subscribes to computed as a source

Watch<​T> - tracking changes

csharp
public Prop<​string> Status = new("idle");

protected override void Created()
{
    Watch(Status, (oldVal, newVal) =>
    {
        if (newVal == "error")
            PlayErrorAnimation();
    });
}

ReturnsIDisposable— for manual unsubscribing:

csharp
var handle = Watch(someProp, callback);
handle.Dispose();  // Later

WatchEffect(Action) - auto-tracking effect

csharp
public Prop<​float> Health = new(100f);
public Prop<​float> MaxHealth = new(150f);

protected override void Created()
{
    WatchEffect(() =>
    {
        var ratio = Health.Value / MaxHealth.Value;
        bar.style.width = Length.Percent(ratio * 100f);
    });
}

Automatically tracks everythingProp<​T> AndComputed<​T>, read inside fn, and restarts fnwhen any of them changes. Returns WatchHandleto unsubscribe.

Internally usesReactiveEffect- a single reactive primitive on which all Bind*methods and WatchEffect. When a component detaches, all subscriptions are automatically cleared ( DisposeAllBindings).

ReactiveEffect - a single reactive primitive (internal)

All bindings (BindText, BindShow, BindVisibility, BindClass, BindList, BindListFor) work through ReactiveEffect:

csharp
// Operating principle (simplified):
private WatchHandle ReactiveEffect(Action fn)
{
    var subs = new List<​IDisposable>();

    void Run()
    {
        foreach (var s in subs) s.Dispose();
        subs.Clear();

        // Auto-track: collect all Prop/Computed read in fn
        using (DependencyTracker.Track(src =>
            subs.Add(src.SubscribeInvalidate(() => ScheduleBindUpdate(Run)))))
        {
            fn();
        }
    }

    Run();
    return new WatchHandle(() => { foreach (var s in subs) s.Dispose(); });
}

Key properties:

  • fn() executed underDependencyTracker.Track()- auto-collection of dependencies
  • Subscribe viaSubscribeInvalidate for each source
  • In case of invalidation - batch restart viaScheduleBindUpdate(one pass per frame)
  • Deduplication viaHashSet<Action>— eliminates repetitions with frequent setters

Helpers

csharp
// P<T> is shorthand for new Prop<​T>
public Prop<​string> Title = P("Default Title");

// C<T> is short for new Computed<​T>
public Computed<bool> IsValid => C(() => !string.IsNullOrEmpty(Title));

// WatchEffect - auto-tracking
protected WatchHandle WatchEffect(Action fn);

Cleaning when disconnected from panel

All subscriptions (Bind*, Watch, WatchEffect) are automatically cleared when the component is detached via DisposeAllBindings()V OnDetachFromPanelHandler. Explicitly call Dispose()not needed on watch handles unless manual control is required.

API

Prop<​T>

csharp
public class Prop<​T> : INotifyBindablePropertyChanged
{
    public T Value { get; set; } // notifies subscribers
    public event Action<​T, T> Changed; // (old, new)
    public static implicit operator T(Prop<​T> p);
    public Prop(T initial = default);
}

Computed<​T>

csharp
public class Computed<​T> : IReactiveSource // itself is the source (push validation)
{
    public T Value { get; } // cached, auto-invalidated
    public static implicit operator T(Computed<​T> c);
    public Computed(Func<​T> fn);
    public void Invalidate();                // force mark dirty
    public void Refresh();                   // recalculate immediately
}

Computed<​T>.Value causesDependencyTracker.RegisterSource(this)— external tracking sees computed as a source. MarkDirty()forwards invalidation to subscribers only at the front false→true.

WatchHandle

csharp
public class WatchHandle : IDisposable
{
    public void Dispose();             // unsubscribe from Prop<​T>
}

IReactiveSource

csharp
public interface IReactiveSource
{
    IDisposable SubscribeInvalidate(Action onInvalidate);
}

Prop<​T> implementsIReactiveSource. Computed<​T>uses it for auto-tracking.

DependencyTracker

csharp
internal static class DependencyTracker
{
    public static IDisposable Track(Action<​IReactiveSource> collector);
    public static void RegisterSource(IReactiveSource source);
}

[ThreadStatic]- thread safe. No obvious DependsOn()no need.


Props between components

When using custom components (<sus:SusButton>) V.sharq.

Literal prop

xml
<!-- variant="primary" - mutates the .Value of an existing Prop, does not replace -->
<sus:SusButton variant="primary" :text="Title" />

Generator issuesSetChildProp(child, "variant", "primary") which:

  1. Finds the fieldVariant(case insensitive, BindingFlags.IgnoreCase)
  2. If the member isProp<​T> and notnull→ writes in.Value(preserves the child's internal bindings)
  3. If the member isProp<​T> Andnull→ creates a new instance
  4. If the member is a regular type → direct assignment

Jet prop

xml
<!-- :variant="item.Kind" - reactive whenever item.Kind changes -->
<sus:SusButton :variant="item.Kind" />

Generator issuesBindChildProp(child, "variant", () => item.Kind) which:

  1. Finds the fieldVariant(case insensitive)
  2. Wraps inReactiveEffect— auto-subscription + cleaning when detaching
  3. Mutates.Value existingProp<​T>

Scalar conversion

csharp
// ConvertScalar(value, targetType) supports:
stringbool (via bool.TryParse)
stringint (via Convert.ChangeType)
stringfloat (via Convert.ChangeType)
string → enum (via Enum.Parse, ignoreCase)
string → string (direct assignment)

Diagnostics

In the dev build (#if DEVELOPMENT_BUILD || UNITY_EDITOR):

  • LogWarning in case of conversion error or unknown prop
  • LogError IfBindChildProp didn't find itProp<​T>-member by name

Open-core: Core & Router MIT · Kit & Game commercial