Lens-Based State Management for GUI Apps

My summer internship project at Geckotech was on making a data analysis dashboard in Rust and WASM. To back this dashboard application, I made X-Bow, a state management library based on lenses. This post explains the backgrounds and motivations for X-Bow.


A state management solution keeps your graphical app codebase sane. It usually provides

  1. a centralized place for your state data to live,
  2. read/write access to the state data, and
  3. some subscription mechanism to keep the UI in sync.

There are many wildly differing designs for state management libraries. However, there is one design that I think is under-explored: state management based on "lenses".

Lenses#

The general idea of lenses originated in the functional programming world but has since proliferated to many languages. We can find the concept discussed in Haskell, in JavaScript, in Rust, etc. The crux is that a lens let you access and update a small piece of your state, without really caring what the full state object looks like (you're focused on just your piece - that's why they're called lenses). Think of lenses as paths to different pieces of data.

a diagram showing a state object and lenses to different pieces in it

(There are more formal definitions of what "lenses" are and what property they must satisfy. For this post, though, we'll stick with just the "paths to pieces of data" idea.)

Using Lenses for State Management#

Every state management library needs to provide a way to refer to different pieces of the state data. Lenses are perfect for this role. Why?

You might think of using borrows or pointers for the job. The problem is those are "flat"; a pointer can only say "address 0x12345 in the heap". Lenses, on the other hand, are structural; they let us refer to "entry with key X in the HashMap at field Y of the state struct".

Some state management libraries use "selectors" to identify pieces of the state data. A selector is a function/closure that takes in the full state data and returns the piece of interest. Unfortunately, selectors are opaque; we don't know how a selector goes from the full state to its target piece. Lenses, in contrast, are transparent. We know exactly what substructures a lens traverses through before getting to its target data.

Now, what can we accomplish using lenses?

Lenses Make Notifications Efficient#

When the application state is updated, the state management library needs to notify all the affected subscribers. But how could the library know which subscribers are affected? Notify too little, and the UI goes out of sync. Notify too much, and the time complexity balloons.

The approach used by X-Bow is very simple. We keep a HashMap of all lenses with active subscriptions. When the data at a path is modified, we wake the subscribers in the HashMap.

a diagram showing that subscriptions to data at a path is only woken if data at that path is changed

But there is a problem: when there are nested structures in the state, change at one path can effect data at others.

(state → b → 0 → c).get(); // == 5678
(state → b → 0).set({ c: 42 });
(state → b → 0 → c).get(); // == 42

Here, we modified the data at state → b → 0, but the data at state → b → 0 → c got changed too in the process!

Other state management libraries face this same problem. Many, including Redux, handle it by diffing the old and new data upon every change. Some, such as Recoil, avoid nested state structure altogether. Others, including MobX, detect runtime access to different state pieces and automatically add subscriptions for them.

The problem is simple to fix under the lens approach. Lenses are transparent. Every lens knows of all the other pieces of the state that it goes through on its way to the target data. All we have to do is subscribe to those pieces of the state too

a diagram showing that subscriptions to data at state → b → 0 → c is woken by change at state → b → 0

We now handle subscription of deeply nested state correctly, and with much better runtime performance than solutions based on diffing or automatic access detection!

Lenses Make Change Logging Simple#

The popular way to implement undo-redo is to take a snapshot of the application state on every change. In order for this to be efficient, the state must be built with cheaply-clonable immutable data structures.

The lenses/paths approach enables us to implement undo-redo without the complexity of immutable data structures. All we have to do is keep a log of change events. Each change event contains a lens pointing to the piece of the state that was changed, and the previous value at that location.

type UndoTape = Vec<Box<dyn Change>>;

struct ChangeEvent<T, P: Path<Out = T>> {
	lens: P,		// which piece of the state was changed?
	prev_value: T,	// what was its previous value?
}

impl<T, P: Path<Out = T>> Change for ChangeEvent<T, P> {
	fn undo(&self) {/*...*/}
}

This implementation is extremely efficient. There is no unnecessary cloning, and only the UI components that depend on the exact data that changed are updated.

diagram comparing the multiple snapshots approach with the more compact change log approach

Lenses Enable Subscribing to Substructure Changes#

In addition to subscribing to changes of data at a specific path, sometimes you want to subscribe to changes of any data inside your path. X-Bow allows you to set up these "bubbling" subscriptions.

This feature is possible because lenses, again, are transparent, knowing what substructures they traverse through. When a lens is changed, it can easily search up its own path to find and wake bubbling subscriptions.

diagram showing change to state → b → 0 → c waking the bubbling subscriber of state → b → 0

There are a lot of potential uses for this. Here are some examples

  • a "save" button that only enables after some data in the form have been modified
  • a graph plotting app that redraws its plot whenever any parameter is modified

Try X-Bow#

If you'd like to try out X-Bow now, here is the library documentation. X-Bow provides an executor-agnostic async API, so you can use it with any UI framework that supports async. I'd recommend in particular Async UI (made by me) or Dominator.