afforded.space

Models before tools

April 12, 2020

I often encounter questions like “how do I build [thing] in [popular UI library]?“. Questions like these usually betray an overestimation of what a UI library is.

Rather than focusing on the API of the tool we’ve chosen, we should first develop a model of the thing we’re trying to build. We should think of UI libraries like React and Vue as layers onto which we can map a generic, unambiguous application model. Doing so naturally encourages clearer, more predictable code.

An oversimplification of the tool

A UI library’s main job is to interact directly with the DOM (or whatever the relevant host tree is) by handling events (e.g. user events, network events). Libraries like React and Vue are really good at efficiently and predictably updating the DOM in response to events. At their core, they are the glue between the DOM and your application’s logic. But they don’t afford a prescribed pattern for organizing that logic. In the absence, it’s very easy for our application logic to get stuck in the “glue”.

Keeping the focus on events, a common pattern is for handlers to use some internal logic to determine what to do next (eg. validations, checking disabled states). But doing so ties application logic into the “component model”. This type of tethering is limiting as your application grows more complex. When a user clicks a button, the entity that determines what happens next should have a full understanding of your application’s state. Our logic is clearest when it can be easily tracked back to user events.

We start to get ourselves into trouble when we begin implicitly fitting application logic onto a component based API. To highlight this, I’m going to pick on React hooks.

Many of us have been guilty of overzealously abstracting functionality into hooks, sprinking them onto a tree of components. Doing so can feel very clean. But if you take a step back you’ll notice some blurriness in your overall application logic. You most likely have a handful of components that look like:

If X changes, do Y. But what was the event that caused X to change? And what other effects did that event have? Has anything else mutated X in the meantime?

React’s component model doesn’t provide a way to explicitly make sense of events. When zoomed into a downstream component, we’ve lost track of our user event. By fitting our application logic into components one at a time, our overall application flow usually becomes less predicatable and more difficult to follow.

Start with a model

Rather than spreading our application logic across our components, we should first develop a model of what we’re trying to build.

Let’s think about a custom select input. Here are the things we’ll need:

Functionality

- ability to open and close a list of options (with mouse or keyboard)
- ability to select an option (with mouse or keyboard)
- ability to arrow up and down the list of options
- fuzzy find options in the open state

Events

- "Closed" state
  - user clicks on the input element (-> open)
  - user presses spacebar with the input focused (-> open)
- "Open" state
  - user clicks on the input element (-> closed)
  - user clicks on an option to select (-> closed)
  - user presses spacebar to select (-> closed)
  - user presses enter to select (-> closed)
  - user arrows down
  - user arrows up
  - user types to fuzzy find option

We’ve sketched out our select input’s possible states and we’ve listed all the events that are relevant to each. This may seem very straightforward — and it is — but the simple act of thinking in terms of events sets us up to organize our logic in a clear and communicative way. For complex application logic, a more robust model is helpful (diagrams help).

So what would it look like to implement this model in a way that simply maps onto our library of choice? Take a look at the Vue component below. Even if you’re not familiar with Vue (I’m not really either), try to focus on where our actual application logic lives:

<template>
  <div>
    <div
      :class="Select"
      @keydown="handleKeydownSelect"
      @click="handleClickSelect"
      tabindex="0"
    >{{ !!state.selected ? state.selected.name : "" }}</div>
    <ul ref="listRef" :class="List" v-show="isOpen()">
      <li
        v-for="decoratedItem in state.decoratedItems"
        ref="itemsRef"
        :key="decoratedItem.id"
        :class="getItemClasses(decoratedItem)"
        @mousemove="() => handleMousemoveItem(decoratedItem)"
        @click="() => handleClickItem(decoratedItem)"
      >{{ decoratedItem.item.name }}</li>
    </ul>
  </div>
</template>

<script lang="js">
**import stuff here**

const Select = defineComponent({
  name: "Select",
  props: { items },
  setup(props) {
    const itemsRef = ref(null);
    const listRef = ref(null);

    const state = reactive({
      selected: null,
      decoratedItems: props.items.map((item) => ({
        ref: null,
        item
      }))
    });

    function handleSelectOption(item) {
      state.selected = item;
    }

    const { state: machineState, send } = useMachine(selectMachine, {
      context: {
        listRef,
        getElementFromRef: ref => ref,
        decoratedItems: state.decoratedItems,
        onSelectOption: handleSelectOption,
        selected: state.selected
      }
    });

    function handleKeydownSelect(e) {
      send({ type: KEY_DOWN_SELECT, charCode: e.which });
    }

    function handleClickSelect() {
      send(CLICK_TRIGGER);
    }

    function handleClickItem({ item }) {
      send({ type: CLICK_ITEM, item });
    }

    function handleMousemoveItem(decoratedItem) {
      send({ type: SET_ACTIVE_ITEM, decoratedItem });
    }

    function isOpen() {
      return machineState.value.value === "open";
    }

    function getItemClasses(decoratedItem) {
      const { context } = machineState.value;
      return classnames('Item', {
        "is-active": isItemActive(decoratedItem, context),
        "is-selected": isItemSelected(decoratedItem, state.selected)
      });
    }

    return {
      state,
      send,
      machineState,
      listRef,
      itemsRef,
      handleKeydownSelect,
      handleClickSelect,
      handleClickItem,
      handleMousemoveItem,
      isOpen,
      getItemClasses
    };
  }
});

export default Select;
</script>

Notice that the functionality we modeled above is entirely encapsulated in the selectMachine module (this module uses a library called XState to encapsulate all of our logic into a finite state machine, a fantastic pattern which I’ll leave for another times). You can view the full code here.

The important thing to note is that our component only has to worry about sending events. Rather than obscuring our logic across our event handlers, a single external module is tasked with making sense of them. Aside from defining these handlers, the only things our component does are instantiate state, refs, templating and styling.

A secondary benefit of defining our logic outside the confines of our library’s API is that it can be easily ported into a different library. The below React component uses the exact same selectMachine module (within a “headless” useSelect hook):

**import stuff here**

function Select({ items }) {
  const [selected, setSelected] = useState(null);

  const {
    isOpen,
    decoratedItems,
    getItemProps,
    getListProps,
    getSelectProps,
    isItemActive,
    isItemSelected,
    state
  } = useSelect({
    onSelectOption: setSelected,
    items,
    selected
  });

  return (
    <>
      <div
        // sets tabIndex, keyboard handlers, click handlers...
        {...getSelectProps()}
        className={classnames('Select', {
          isOpen
        })}
      >
        {!!selected ? selected.name : ""}
      </div>
      <ul
        // sets ref, used for focus, scrolling etc...
        {...getListProps()}
        className={classnames('ListBox', {
          hidden: !isOpen
        })}
      >
        {decoratedItems.map((decoratedItem) => (
          <li
            // sets ref, keyboard handlers, click handlers...
            {...getItemProps(decoratedItem)}
            key={decoratedItem.item?.id}
            className={classnames('Item', {
              "is-active": isItemActive(decoratedItem, state.context),
              "is-selected": isItemSelected(decoratedItem, selected)
            })}
          >
            {decoratedItem.item?.name}
          </li>
        ))}
      </ul>
    </>
  );
}

export default Select;

Notice that the majority of the lines are used for styling. A key benefit in untethering our application’s logic from our UI library is that it makes our codebases more readable (potentially allowing for less technical folks, e.g. designers, to contribute to implementations).

Clear and predictable

We should always strive for clear and predictable code. Rather than relying on what’s convenient given our tooling, we should first outline a model of the thing we’re trying to build. When modeling interfaces, our goal is to define states and their events. Without this type of practice, modern UI libraries make it easy to obscure application logic by spreading it across our components.


Get new posts sent to your inbox