Lens-Based State Management for GUI Apps

by Wisha Wanichwecharungruang  on December 5, 2023

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 it.

State Management

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.

(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 actually do with 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.


 

But there is a problem: when there are nested types 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



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 the 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.


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.

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.