3. Reactivity
Updated: 2026-07-01 - added props between components via
SetChildProp/BindChildProp.
Prop<T> - reactive property
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(throughimplicit operator) - Comparison by value: if new == old, event
Changednot called - IL2CPP-safe: does not use reflection
Computed<T> - calculated property
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:
- Chains
Prop → Computed A → Computed B → BindTextwork (push invalidation up the chain) BindText(label, () => MyComputed.Value)— subscribes to computed as a source
Watch<T> - tracking changes
public Prop<string> Status = new("idle");
protected override void Created()
{
Watch(Status, (oldVal, newVal) =>
{
if (newVal == "error")
PlayErrorAnimation();
});
}ReturnsIDisposable— for manual unsubscribing:
var handle = Watch(someProp, callback);
handle.Dispose(); // LaterWatchEffect(Action) - auto-tracking effect
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:
// 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 via
SubscribeInvalidatefor each source - In case of invalidation - batch restart via
ScheduleBindUpdate(one pass per frame) - Deduplication via
HashSet<Action>— eliminates repetitions with frequent setters
Helpers
// 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>
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>
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>.ValuecausesDependencyTracker.RegisterSource(this)— external tracking sees computed as a source.MarkDirty()forwards invalidation to subscribers only at the frontfalse→true.
WatchHandle
public class WatchHandle : IDisposable
{
public void Dispose(); // unsubscribe from Prop<T>
}IReactiveSource
public interface IReactiveSource
{
IDisposable SubscribeInvalidate(Action onInvalidate);
}Prop<T> implementsIReactiveSource. Computed<T>uses it for auto-tracking.
DependencyTracker
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
<!-- variant="primary" - mutates the .Value of an existing Prop, does not replace -->
<sus:SusButton variant="primary" :text="Title" />Generator issuesSetChildProp(child, "variant", "primary") which:
- Finds the field
Variant(case insensitive,BindingFlags.IgnoreCase) - If the member is
Prop<T>and notnull→ writes in.Value(preserves the child's internal bindings) - If the member is
Prop<T>Andnull→ creates a new instance - If the member is a regular type → direct assignment
Jet prop
<!-- :variant="item.Kind" - reactive whenever item.Kind changes -->
<sus:SusButton :variant="item.Kind" />Generator issuesBindChildProp(child, "variant", () => item.Kind) which:
- Finds the field
Variant(case insensitive) - Wraps in
ReactiveEffect— auto-subscription + cleaning when detaching - Mutates
.ValueexistingProp<T>
Scalar conversion
// ConvertScalar(value, targetType) supports:
string → bool (via bool.TryParse)
string → int (via Convert.ChangeType)
string → float (via Convert.ChangeType)
string → enum (via Enum.Parse, ignoreCase)
string → string (direct assignment)Diagnostics
In the dev build (#if DEVELOPMENT_BUILD || UNITY_EDITOR):
LogWarningin case of conversion error or unknown propLogErrorIfBindChildPropdidn't find itProp<T>-member by name