State Management for Semagrams

In this post, I am going to lay out some of the rationale and background for design decisions around state management in Semagrams, partly as a retrospective, and partly in order to plan the future.

cats-effect: explicit state vs. process state

Semagrams uses the Laminar UI library in order to interact with the browser. Laminar is a fantastic library, and I encourage you to read this introduction if you want to learn more about it. However, in addition to Laminar, Semagrams also uses a library called cats-effect. cats-effect and Laminar, while not incompatible, weren’t designed to work together. Why do I think this is worth the additional complexity it introduces?

The answer is that cats-effect allows writing resumable procedures. In order to understand what this means, consider the task of writing a Semagrams tutorial. This might look something like

<give instruction to the user>
<wait for the user to complete task>
<give next instruction>
<wait for user>
etc...

The intuitive way of implementing this would be to simply write an imperative function which called methods like display(instruction), waitForCompletion(), where waitForCompletion() blocks the thread of execution until the user has done something. However, javascript is single-threaded; if we block the thread then nothing else can happen!

Another way of implementing this would be to make an explicit state machine that processes messages. We have a state for each line in the tutorial, and certain messages either update the state or keep the state the same. The state would be represented by an enumeration that looked like:

enum State {
  case Step1
  case Step2
  ...
}

Then we’d have a function which looked like

enum Message {
  case Completed
  case NotCompleted
}

def process(state: State, message: Message): State = state match {
  case Step1 => message match {
    case Completed => { display(step2Instructions); Step2 }
    case NotCompleted => Step1
  }
  case Step2 => message match {
    case Completed => { display(step3Instructions); Step3 }
    case NotCompleted => Step2
  }
}

But manually writing out this kind of state machine via match statements and enumerations is very tedious.

Another option would be something like:

case class State(next: Message => State)

val state1 = State(
  msg => msg match {
    case Completed => {
      display(step2Instructions);
      state2
    }
    case NotCompleted => initState
  }
)

val state2 = State(
  msg => msg match {
    case Completed => {
      display(step3Instructions);
      state3
    }
    case NotCompleted => state2
  }
)

val state3 = ...

This is a much more flexible way of dealing with the state machine, because we don’t have to explicitly enumerate every single state, and we don’t have to write all of our logic inside of a giant match expression.

However, we still have to explicitly define each state. This is similar to a programming language where the only control flow is GOTO. Really, we just want to program in the style of the first imperative program, but have it work.

This is what asynchronous programming gets us. With cats-effect and cats-effect-cps, we can write a program that looks like

def tutorial: IO[Unit] = async {
  display(step1Instructions)
  waitForUser().await
  display(step2Instructions)
  waitForUser().await
  ...
}

and the .await lines “logically” block the chain of execution for tutorial until a certain condition is met to wake tutorial back up, while allowing other computations to run in the meantime.

Having this ability to suspend and resume a logical thread is incredibly useful for user interaction, as complex interactions that involve multiple user actions can be written using familiar high-level programming constructs like if statements, while loops, and function calls.

How to separate concerns

In Semagrams, we’ve used cats-effect for this from the beginning. However, although it is possible to integrate cats-effect and Laminar together in a variety of ways, the overlap in functionality between cats-effect and Laminar has caused some confusion about which one should be used for a given part of Semagrams.

In this post, I hope to outline a philosophy of state management that should make these decisions more clear.

This philosophy boils down to one idea: all state should have a single “owner” which is allowed to mutate that state. If anything that is not the owner of a state wishes to modify that state, it must send a message to the owner, which the owner is free to handle in whichever way it wants.

In redux, which is a javascript state management library often paired with React, the combination of “state and owner” is called a store, and we will use this terminology as well.

This idea is slightly weaker than the Rust principle of “no shared, mutable state”, because the single mutating reference to the state does not exclude the possibility of having many read-only references to that state.

UI should then be created based on the exported read-only reference of a store.

This gives a nice way to make a separation of concerns between Laminar and cats-effect. The interface for a store should allow for messages to be sent to it from either Laminar or cats-effect, and moreover a store should allow reading of its state from either Laminar or cats-effect. A store could then be implemented with asynchronous code from cats-effect, or via something more like a regular old state machine, and downstream code can be agnostic to which choice is made.

Semagrams as a component in a larger app

In Refactor for embedding support by olynch · Pull Request #141 · AlgebraicJulia/Semagrams.jl · GitHub, I refactor Semagrams into two parts. One is a pure-Laminar component, which displays the Semagram and fires events, and the other is a mix of Laminar and cats-effect code which processes those events. This processing of events mutates state directly. This makes it unclear whether when a Semagram-based editor is part of a larger app, should the editor “own” the underlying data of the Semagram, or should the larger app own it?

I think the answer is that the editor should not own the underlying data. Rather, the editor should interpret user actions within the editor into “edits”, expressed as diffs of the underlying data, and then send these diffs off to some store. The store can then interpret those diffs as it sees fit, integrating them with diffs that may be coming in from other sources as well, like other collaborators via a websocket, or other views of the data.

However, the editor should own some of its own state, for instance what entities in the Semagrams are highlighted or hovered by actions.

So in the context of a larger app, Semagrams is used by creating a store for the underlying data, creating a store for the editor state, creating a view, and then hooking them all up together. Implementing this vision will be a focus of near-term Semagram development.