Reactive State

Reactive state​

state declares a reactive value owned by one component instance. The type may be explicit or inferred from the initializer:

state count = 0;
state int selectedIndex = 0;
state string query = "";

The initializer runs when that state is first created. A normal render caused by another value does not recreate it. Assigning an equal value is ignored by the default equality comparer. A normal initializer is a snapshot, not a computed state: state int doubled = count * 2; does not recalculate when count changes. Use an expression binding such as Text=${x.bind $"Double: {count * 2}"} for a reactive calculated property value instead of a derived state snapshot. A normal markup expression follows the render cycle; composable hooks provide other derived-state tools.

Directional connections​

A mode immediately after = connects a state value to a C# binding path:

state MyViewModel vm = new MyViewModel();

state string? name = bind vm.Name;
state string fullName = out vm.FullName;
state string? surname = in vm.Surname;
Declaration Data flow Can component code assign the state?
state T value = expression; initializer → state once Yes
state T value = bind source.Path; source ↔ state Yes
state T value = out source.Path; source → state No
state T value = in target.Path; state → target Yes

bind reads the initial source value, follows source notifications, and writes state changes back. After a write it reads the target again, so a normalizing setter such as value.Trim() becomes the final state value instead of causing an echo loop.

out follows the source but exposes a shallow-readonly state to component code. Assignments, compound assignments, increment/decrement, deconstruction, ref and out writes are errors, including inside handlers and hook callbacks. This does not make an object stored in the state deeply immutable.

in writes later state assignments to the current target. When the target has an accessible getter, its value is read once for compatible initialization; later target changes do not create a reverse channel. A write-only target starts at default(T) and its setter is not called merely by creating the connection. For a write-only target the state type must be explicit because there is no readable value from which to infer T. For a nullable/reference type, the state can therefore be null until the first component assignment.

Binding paths and observation​

Directional initializers accept identifiers, member access and indexers:

state string name = bind vm.Name;
state string surname = in vm.People[0].Surname;
state string title = out vm.Current.Title;

An arbitrary calculation is not a path. Keep it as a normal initializer or use a derived hook:

state int snapshot = vm.Age + 1;
// state int invalid = bind vm.Age + 1;

Akbura observes INotifyPropertyChanged, Avalonia property changes, and INotifyCollectionChanged for indexed segments. A plain List<T> or an ordinary getter cannot announce every mutation. bind and out report the existing non-observable-source warning when no supported notification channel is present.

Replacing an observable root, intermediate object, or collection item rebuilds the path. Events from the old owner are disconnected. If a root or intermediate value is null, Akbura does not create an object and does not write to the old owner. Read directions retain the last successful value (or default(T) before the first value). Writes performed while an in/bind target is disconnected are not queued or replayed; after reconnection, bind reads the current target, while in waits for the next different state assignment.

Observable sources​

out may consume IObservable<T> directly:

state string message = out vm.Messages;

The value visible in Akbura is T; the observable object is only the source. Before the first OnNext, the value is default(T). OnCompleted retains the last value and releases the subscription. OnError releases the subscription and rethrows the same exception to the producer's call path; it is not swallowed or converted into a target write. A normal state stream = vm.Messages; stores the stream itself and does not subscribe.

A pure IObservable<T> is not a writable channel, so it cannot be used for bind or in merely because it can publish values.

Runtime ownership and Hot Reload​

The connection belongs to the state and component, not to a particular control that reads it. Detaching a component suspends owned subscriptions; reattaching restores one connection to the current source. Akbura never disposes the view model, collection, or observable itself.

An unrelated Hot Reload edit preserves compatible local state. The connection identity includes its mode and path, so changing bind to out, or changing the path, replaces the old binding resources. A state type change uses the existing incompatible-state policy instead of casting State<OldT> to State<NewT>.

State values and hooks​

In ordinary Akbura expressions, count has its value type such as int, not the technical runtime type State<int>, and no .Value access is required. When an existing hook parameter explicitly expects State<T>, the compiler passes the same live state object according to the hook contract:

using Akbura.Hooks;

state int count = 0;
state int delayed = useDebounce(count, 300);

An out state cannot be passed to a hook parameter that exposes mutable State<T> access; use a value/read-only hook contract instead.

The editor offers bind, out, and in only in the mode position after =. After a mode it switches to normal typed C# path completion. Hover shows the value type, direction, and readonly status. Ordinary explicitly typed initializers keep their expected type, including target-typed new() and lambda parameters.