-- Leo's gemini proxy

-- Connecting to gem.twunk.uk:1965...

-- Connected

-- Sending request

-- Meta line: 20 text/gemini

Async for UI Control Flow


Content warning: JavaScript.


In Card Story (link at the end), the users can do things like "edit a line of text", or "create a link from a branch in one card to another card". Like most user interfaces, some things the users do involve multi-step flows. One of the questions when developing user interface code is how to logically represent this kind of multi-step flow.


One possible way is to create an explicit state machine. This can be as simple as defining an enum of possible states and having a variable somewhere to keep track of what state you're in, or you can create or use a whole "framework" for it, such as XState.


Explicit state machines have the advantage that you can draw neat diagrams of them to help you think through, understand, debug, and communicate the flow through your user interface.


Another possible way is to make wholly ad-hoc state machines by having a bunch of variables related to specific parts of your (implicit) state machine and having each 'state' be some unique combination of values of those variables. This is what tends to happen if you build up user interface logic organically without much thought to its overall structure. It can be ok as long as the user interface interaction is pretty simple, but it's easy to end up with weird bugs.


As an aside, here's a stupid bug that I've seen in multiple (totally separate) user interfaces: You have some kind of interface where clicking on something opens a modal dialog with some extra information (or any kind of modal dialog). Because so much modern software is... just... horribly, horribly slow, it is fairly often possible to click *multiple times* before the dialog actually opens. And what I have often seen is: If you click multiple times, you will get *multiple copies* of the dialog. This is of course completely ludicrous if you're thinking of the UI in terms of an explicit state machine. Having the dialog closed is one state, having it open is another state. That's two states. You don't need more states than that. You don't need an unbounded number of dynamic states. You just need two states: open, or closed. If the interface was implemented with an explicit state machine, then you would never see this bug: there would be one internal variable storing 'which state' the system is in, and clicking the thing would (a) switch to the dialog-open state if not in that state, or (b) do nothing if we're already in the dialog-open state. There would not be any code path to "open a second copy of the dialog" because one of the things about an explicit state machine is that state transitions are atomic, the transition function is a pure function that takes you from the old state to the new state (and then the edge you followed may have some extra action associated with it).


Anyway, that was just an aside. I just find that bug stupid.


So finally, what this post is about: Another way, besides the explicit state machine approach and the ad-hoc bag-of-variables approach, is to use `async`. Several languages have something like `async` now, but I'll talk about JavaScript because I'm using Card Story as an example, and Card Story is a web thingy so it's implemented in JavaScript (well, Typescript).


With async, the state machine is the logical flow of your code, using normal code control flow constructs. For example, here is the flow for creating or editing a link between cards (edited a bit for brevity):


  private async runLinkEditSequence(): Promise<void> {
    try {
      // Display the component as waiting for its edit lock.
      this.componentDispatch((st) => ({ ...st, status: TextItemStatus.WaitingForLock }));
      // Request the branch edit lock from the server, wait to get it.
      await this.controller.sendRequest({
        action: "ActBranchLinkOpen",
        card: this.itemRef.card,
        branchIndex: this.itemRef.index,
      });

      // Display the component with edit controls.
      this.componentDispatch((st) => ({ ...st, status: TextItemStatus.EditingLink }));

      // Sub-flow: UI flow for the user to select a card to link to, or cancel the edit.
      // Wait for the sub-flow to complete.
      const targetCardId = await this.cardStreamMachine.runLinkEditSequence(this.itemRef);

      const item = this.controller.story.mustGetBranch(this.itemRef.card, this.itemRef.index);
      const toCard = (targetCardId !== "new" ? targetCardId : undefined) ?? item.toCard;

      // Display the component as "saving".
      this.componentDispatch((st) => ({ ...st, status: TextItemStatus.WaitingForCommit, toCard }));
      // Send update to the server to commit the new link state, and wait for it.
      const commitOk = await this.controller.sendRequest({
        action: "ActBranchLinkCommit",
        card: this.itemRef.card,
        branchIndex: this.itemRef.index,
        toCard: targetCardId ?? item.toCard,
      });
      ASSERT(commitOk.update.event === "EvtBranchLinkCommit");

      // Sub-flow: Display the component as "saved" for a period of time, then flip back to "idle".
      await this.runCommittedSequence();
    } catch (e: unknown) {
      console.log(`failure: ${JSON.stringify(e)}`);
      // Reset to story-managed state if there was an error or action rejection.
      this.componentDispatch((_st: TextItemState) => this.stateFromStory(this.controller.story));
    }
  }

With an explicit state machine that's something like 5 states (idle, waiting for lock, editing, saving, saved).


The nice things about using 'async' for this:


Sub-flows, for abstraction or for re-use, are fairly easy.

Fits nicely with non-user actions that are part of the overall flow (e.g., sending something over the network and waiting for the result).

Keeps all the code for a particular user-interaction sequence together, and makes it pretty much "straight line" code.


Things that are maybe not so nice:


Gotta be careful about the underlying implementations of the async user-interaction actions. In other words, implementing the async UI "primitives" needs care.

It seems to work pretty well for short/small sequences, but maybe it's not so nice for things that would be bigger state machines? Not sure.

You can't do neat stuff with, e.g., easily drawing a state machine diagram.


Anyway, over all I'm glad I tried doing it this way. I think it's pretty neat. But I haven't used this structure enough to be sure whether it's a really good general purpose architectural choice for UI flows.


References:


XState javascript library.

Card Story (runs in browser, needs javascript)

-- Response ended

-- Page fetched on Sun May 12 07:19:50 2024