David Vedvick

Notes

The Interaction State Primitive

I've been developing UI's in some form or another for about 25 years. It's taken nearly that long to realize the basic primitive of dynamic UI's: what I call the "Interaction State" primitive. The Interaction State primitive has these rules:

  1. The current state of the property can be accessed via a normal get accessor. In Java, this might be a method with a signature like T getValue(). Sometimes this is called a snapshot, but I prefer the more direct terminology of state. In a language with more syntactical magic available, it can be done via something like a get accessor.
  2. Its value can be updated via a normal set accessor. In Java, this might look like setValue(T value). Again, in other languages, syntax magic can make the write access more terse.
  3. Updates to the property's state are observable. Below, I will cheat and use the ReactiveX flavors to achieve this.
  4. setValue(T value) only notifies observers the value is updated when the new value is not equal to the existing value. This should follow normal equality rules of the language.

I am not the first to make this observation; KnockoutJS, which I used c.a. 2014, had observables in Javascript, and Rx.Net and other ReactiveX flavors have been around for quite some time, with the BehaviorSubject<T>, which has the above semantics, but with slightly contorted syntax. However, in my opinion, all of these languages and libraries don't make it plain that these are the rules they're following. Perhaps the authors of these API's think these rules are obvious and don't need to be broadcast. However, these rules weren't made plain to me until I came across the StateFlow type in Kotlin: upon observing it's simplicity, the lack of too much magic or layers of obfuscation, this raw form of the Interaction State primitive made it all obvious to me.

What benefits does this Interaction State primitive give?

  • When used as the main representation of the data state in a View Model, they're easily testable; the current state of the property can be taken just by calling getValue(), and if you want to validate how the property changes over time, you can easily do that too.
  • When the observable portion is implemented using something like the Reactive extensions, you can observe its value over time with endless numbers of operators. Using something very generic like Reactive extensions instead of something like StateFlow leaves you very free of any assumptions made about the runtime or environment; for example, you don't have to mess with coroutine contexts or anything like that.
  • Integrates very nicely with dynamic UI frameworks; React obviously has its state variables, Jetpack Compose has the magical stateOf and mutableStateOf, and C# has the INotifyPropertyChanged magical interface, and the Interaction State primitive can integrate very nicely with all of them! It also integrates well with something like WinForms.

I want to show some examples of how this primitive can be used in different languages, with a fairly simple use case: given expenses and incomes, display the networth.

Kotlin and Jetpack Compose

Here's an example in Kotlin, integrating with Jetpack Compose:

// The read-only interface for the Interaction State Primitive
abstract class InteractionState<T> : Observable<NullBox<T>>() {
    abstract val value: T
}

// A mutable implementation of the read-only interface for the Interaction State Primitive.
class MutableInteractionState<T>(private val initialValue: T) : InteractionState<T>() {

    // RxJava has the unfortunate design decision of nulls not being allowed as values, so we 
    // box the value to allow nulls.
    private val behaviorSubject = BehaviorSubject.createDefault(NullBox(initialValue))

    override var value: T
        get() = behaviorSubject.value?.value ?: initialValue
        set(value) {
            if (behaviorSubject.value != value)
                behaviorSubject.onNext(NullBox(value))
        }

    override fun subscribeActual(observer: Observer<in NullBox<T>>?) {
        behaviorSubject.safeSubscribe(observer)
    }

    fun asReadOnly(): InteractionState<T> = this
}

class LiftedInteractionState<T: Any>(source: Observable<T>, private val initialValue: T) : InteractionState<T>(), AutoCloseable {

    private val behaviorSubject = BehaviorSubject.createDefault(NullBox(initialValue))
    private val subscription = source.distinctUntilChanged().subscribe { behaviorSubject.onNext(NullBox(it)) }

    override val value: T
        get() = behaviorSubject.value?.value ?: initialValue

    override fun subscribeActual(observer: Observer<in NullBox<T>>?) {
        behaviorSubject.safeSubscribe(observer)
    }

    override fun close() {
        subscription.dispose()
    }
}

class MoneyViewModel(accountManager: ManageAccounts) : ViewModel() {
    val expenses = MutableInteractionState(0.0);

    val income = MutableInteractionState(0.0);

    val networth = LiftedInteractionState(
        Observable.combineLatest(expenses, income).map { expenses, income -> income - expenses },
        0.0);

    suspend fun loadAccount(int accountId) {
        var account = accountManager.loadAccount(accountId);
        expenses.Value = account.Expenses;
        income.Value = account.Income;
    }
}

// easily map MutableInteractionState to mutableStateOf(...)
@Composable
fun <T, S : MutableInteractionState<T>> S.subscribeAsMutableState(
    context: CoroutineContext = EmptyCoroutineContext
): MutableState<T> {
    val state = remember { mutableStateOf(value) }
    DisposableEffect(key1 = this) {
        val disposable = subscribe { state.value = it.value }

        onDispose {
            disposable.dispose()
        }
    }

    LaunchedEffect(key1 = this) {
        value = state.value
        if (context == EmptyCoroutineContext) {
            snapshotFlow { state.value }.collect {
                value = it
            }
        } else withContext(context) {
            snapshotFlow { state.value }.collect {
                value = it
            }
        }
    }
    return state
}

// The Compose view
fun MoneyView(viewModel: MoneyViewModel) {
    with (viewModel){
        var expensesState by expenses.subscribeAsMutableState()
        StandardTextField(
            placeholder = stringResource("Debit"),
            value = expensesState,
            onValueChange = { expensesState = it }
        )

        var incomeState by income.subscribeAsMutableState()
        StandardTextField(
            placeholder = stringResource("Credit"),
            value = incomeState,
            onValueChange = { incomeState = it }
        )

        val networth by networth.subscribeAsState()
        Text(networth);
    }
}

Typescript and React

Here's my usage in Typescript/React:

// Build up the type definitions
export interface InteractionState<State> extends Subscribable<State> {
    get value(): State;
}

export interface UpdatableInteractionState<State> extends InteractionState<State> {
    set value(state: State);
}


// With Typescript, we have the advantage that we can just inherit from the existing `BehaviorSubject` and here
// make `value()` read-only via interface. Of course it's not enforced in the runtime, but it's good enough for us.
class MutableInteractionState<T> extends BehaviorSubject<T> implements UpdatableInteractionState<T> {

    constructor(initialValue: T) {
        super(initialValue);
    }

    set value(value: T) {
        this.next(value);
    }
}

class LiftedInteractionState<T> extends BehaviorSubject<T> implements InteractionState<T> {

    constructor(observable: Observable<T>, initialValue: T) {
        super(initialValue);
        observable.subscribe(this);
    }
}

export function liftInteractionState<T>(observable: Observable<T>, initialValue: T): Observable<T> & InteractionState<T> & SubscriptionLike {
    return new LiftedInteractionState(observable, initialValue);
}

export function mutableInteractionState<T>(initialValue: T): Observable<T> & UpdatableInteractionState<T> & SubscriptionLike {
    return new MutableInteractionState(initialValue);
}

export function useInteractionState<T>(interactionState: InteractionState<T>): T {
    const [state, setState] = React.useState(interactionState.value);

    React.useEffect(() => {
        const sub = interactionState.subscribe({ next: setState });
        return () => sub.unsubscribe();
    }, [interactionState]);

    return state;
}

export function useMutableInteractionState<T>(interactionState: UpdatableInteractionState<T>): [T, Dispatch<SetStateAction<T>>] {
    const state = useInteractionState(interactionState);

    return [
        state,
        (stateOrAction: SetStateAction<T>) => {
            if (stateOrAction instanceof Function) {
                interactionState.value = stateOrAction(interactionState.value);
                return;
            }

            interactionState.value = stateOrAction;
        }];
}

export class MoneyViewModel {
    readonly income = mutableInteractionState(0);
    
    readonly expenses = mutableInteractionState(0);
    
    readonly networth = liftInteractionState(
        combineLatest([this.income, this.expenses]).pipe(map(([i, e]) => i - e)),
        0);
}

const vm = new MoneyViewModel();

export function MoneyView() {
    const income = useInteractionState(vm.income);
    const expenses = useInteractionState(vm.expenses);
    const networth = useInteractionState(vm.networth);

    return <div>
        <input type="text" value={income} onChange={e => vm.income.value = Number.parseFloat(e.target.value)}/>
        <input type="text" value={expenses} onChange={e => vm.expenses.value = Number.parseFloat(e.target.value)}/>
        <div>
            <h3>Networth:</h3>
            <p>{networth}</p>
        </div>
    </div>;
}

C# and WPF

interface IInteractionState<T> : IObservable<T>
{
    public T Value { get; }
}

// Implementing INotifyPropertyChanged as well allows it to be used with WPF/XAML bindings
class MutableInteractionState<T> : IReadOnlyObservableProperty<T>, INotifyPropertyChanged, IDisposable
{

    private readonly BehaviorSubject<T> subject;

    public event PropertyChangedEventHandler PropertyChanged;

    public MutableInteractionState(T initialValue)
    {
        subject = new BehaviorSubject<T>(initialValue);
    }

    public T Value
    {
        get => subject.Value;
        set
        {
            if (!EqualityComparer<T>.Default.Equals(value, subject.Value))
            {
                subject.OnNext(value);
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
            }
        }
    }

    public IDisposable Subscribe(IObserver<T> observer) => subject.Subscribe(observer);

    public override void Dispose() => subject.Dispose();
}

// A read-only observable
class LiftedInteractionState<T> : IReadOnlyObservableProperty<T>, INotifyPropertyChanged
{
    private readonly BehaviorSubject<T> subject;
    private readonly IDisposable sourceSub;

    public event PropertyChangedEventHandler PropertyChanged;

    public LiftedInteractionState(IObservable<T> source, T initialValue)
    {
        subject = new BehaviorSubject<T>(initialValue);
        sourceSub = source.Subscribe(v => Value = v);
    }

    public T Value
    {
        get => subject.Value;
        private set
        {
            if (!EqualityComparer<T>.Default.Equals(value, subject.Value))
            {
                subject.OnNext(value);
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
            }
        }
    }

    public IDisposable Subscribe(IObserver<T> observer) => subject.Subscribe(observer);

    public override void Dispose()
    {
        sourceSub.Dispose();
        subject.Dispose();
    }
}

class MoneyViewModel
{
    private readonly IAccountManager accountManager;

    public MutableInteractionState<decimal> Debit { get; } = new(0);

    public MutableInteractionState<decimal> Credit { get; } = new(0);

    public IInteractionState<decimal> Networth;

    public MoneyViewModel(IAccountManager accountManager)
    {
        this.accountManager = accountManager;
        this.Total = new LiftedInteractionState<decimal>(
            this.Credit.CombineLatest(this.Debit).Select(tuple =>
            {
                var (credit, debit) = tuple;
                return credit - debit;
            }),
            0);
    }

    public async Task LoadAccount(int accountId) {
        var account = await accountManager.LoadAccount(accountId);
        Debit.Value = account.Debit;
        Credit.Value = account.Credit;
    }
}

<Window>
    <Grid>
        <TextField Value={Binding Debit.Value} />
        <TextField Value={Binding Credit.Value} />
        <TextLabel Value={Binding Networth.Value} />
    </Grid>
</Window>

In my opinion, removing the obscurity around this primitive has a similar effect to understanding the Promise; it opens up and simplifies a whole class of programming problems: with the Promise, a single definition is wrapped around building asynchronous functions and receiving their results, likewise, for the "Interaction State" primitive, a single definition is given to making a UI that reacts to inputs agnostic of the source; in other words, regardless of whether the UI needs to respond to user input or the UI needs to respond to asynchronous calls, the situation will be handled in the same way by the "Interaction State" primitive. It also enables a programmer to use it for purposes beyond what the implementations above restrict the user to; for example, I've used the Kotlin implementation in both Android XML views and Jetpack Compose, likewise, I've used the C# implementation for common code between WinForms views and WPF views. This allows my view models to remain stable, while only the decoration is updated with new binding syntax.

Note posted on Saturday, January 27, 2024 11:47 AM CST - link