DECISION NODE

February 1, 2024
Decision Nodes

Dynamic toolbars and menus

Yoel Tadmor
,ㅤ
Head of Engineering @ Eraser

Just about any app will include one or more toolbars or menus that provide a UI for users to perform actions or change configuration. These can range from simple three-dot menu dropdowns with one or two actions to all-encompassing Command+K  style. And sometimes, our menus might become more advanced as new functionality is added:

Open in Eraser

This article explores two approaches to implementing a dynamic list of actions for a UI that ends up something like this:

  1. Imperative container
  2. Registration and config-based

We explore these choices for several dimensions:

  • Which actions to show
  • How to render a particular action
  • How to order and group actions into groups and sub-menusLastly, we'll discuss what a hybrid approach can look like.

Note: Knowledge Hierarchy

Before we dive in, let's quickly explain the idea of a knowledge hierarchy:

A knowledge hierarchy maps your information architecture onto your component hierarchy. It asks which components need to know about which pieces of data or state in order to implement business logic or other product requirements. Looking at our menu, it might break down roughly like this:

Open in Eraser

One thing to note: a knowledge hierarchy is not analogous to component props, even though they are related. The knowledge hierarchy is more about which levels in the tree need to be aware of particular requirements.

Example: We decide we want to show a special message if every action inside of a group is disabled. In that case, we don't list the individual actions. Previously, only the ActionItem component needed to know that an action might be disabled and rules around how to render it. In order to implement this requirement, the Group component needs to be aware that actions might be disabled.

Why does this matter? Because once a component thinks it knows something, it can also be wrong about that thing.

Example: We want to distinguish between actions that disabled because they require a plan upgrade (which we want to render with a special badge to drive conversion) vs. other actions that are disabled. Actions with the badge should always be rendered, but our Group  component was never updated, and hides them if the other actions are disabled. Oops!

As a result, there is a similar tension as managing state:

  • Keeping knowledge as far down the tree as possible is valuable, but...
  • Grouping bits of knowledge into one "smart" component makes it easier to know where something is implemented and where we need to make changes

Imperative Container

In this approach, the the container acts like an omnipotent, omniscient orchestrator. Our knowledge hierarchy is such that everything is crammed into the Container . It contains all the business logic for choosing which items to show or disable, how to group them, and how to organize them into sub-menus.

Often, this approach will also lead to components lower in the hierarchy being simpler or dumber, as the orchestrating container can make decisions about how they should render.

Open in Eraser

Requires no abstraction

For smaller lists of items, imperative orchestration has the advantage that it makes our code easier to follow. It may even fit neatly in one file.

Sharing logic is easier

Often times, many actions will be disabled or not disabled together. This logic may be really simple, such as checking user.role , but we can also have a litany of other things we want to check:

  • Is the resource locked or archived
  • Did the user create this resource
  • Is the app in a read-only mode for some other reason
  • Is there a particular selection state
Curious about implementing more advanced access controls? Check out our article on the topic.

In this case, implementing this logic once and storing it in a single variable can DRY up code and make it easier to implement new features. It can also mean we avoid entire code paths if, for example, read-only mode means that we simply don't show most groups.

Registration & Config

In this approach, we created an ActionRegistry with a register  method and define an Action interface that allows each individual action to specify when it should be shown, disabled, and so forth.

Open in Eraser

This approach is also generally paired with shifting the knowledge hierarchy downward.

Example: Our ActionItem  component can be made smart enough to know that if it has a sub-menu with a single item, it should instead just render that action directly in the main list.

Scales to many actions

Regardless of how many actions we have, the amount of organization we need doesn't change. We don't need to worry about splitting code once we add a new set of actions, and finding the rules for whether a particular action is disabled or how it executes is much simpler.

Can share logic with keyboard-based flows

Keyboard shortcuts for actions can be combined with this approach by adding a keyboardShortcut field to our config and having a centralized piece of logic that checks our ActionRegister  and subscribes to keydown events. No more bugs where a keyboard shortcut breaks because the isDisabled  check drifts from the menu logic (or vice versa)!

Testing (some things) is faster

Now that we've implemented our ActionRegister, we easily add unit tests that  pass in a specific state and confirm that these exact 5 actions are rendered. And we can do this without needing to mimic a DOM or write end-to-end tests.

Runtime registration simplifies plugins, flags, and async loading

Using our new abstraction, we can register actions at runtime instead of during the build / compile step. This means we can consistently handle a number of different requirements without needing to build ad-hoc mechanisms:

  • Optional plugins, feature flagged action sets, etc.
  • Our Create from template  group that need to make an API calls to determine what the available actions are
  • Registering some actions on certain routes or views (and implementing an unregister  to keep performance tidy)

Render patterns can be built into abstraction

Using our abstraction, we can get consistent render patterns. Examples include:

  • If a sub-menu only has one item, render it at the top level of the group
  • If a group consists entirely of a single sub-menu, render all items at the top level

... but exceptions can be awkward

At first, we're happy with rendering happening in the order that actions are registered. But now we have a new requirement: the Create from Template group should be rendered first if the user has defined custom templates, but at the end of the list otherwise..No worries! We implement a new abstraction within the Container to re-sort the groups based on state: sortedGroups = sortByPriority(groups, state)

Then another requirement: Access actions should always appear in a sub-menu, even if they are singletons. No worries! We add a new property on our Action interface: alwaysSubMenu .

Then another requirement...

Because everything runs through an abstraction instead of imperative execution, we need to build bigger or more abstractions in order to handle new types of requirements. Over time, we can accumulate a lot of cruft, and cruft is notoriously hard to remove.

Hybrid Approach

Some architectural decisions are all-or-nothing. Thankfully, this isn't one of them! We can still capture the main benefits of the registration  approach while keeping a lot of render-related logic inside the Container component.

In that approach, we can simplify our Action interface:

Action {  
  id: string  
  isDisabled: State => bool  
  execute: State => Result
}

This lets us organize our checks and still re-use the logic for our keyboard shortcut registry, our help menus, and so on, but still leaves all the decisions about how to actually display the actions to the orchestrating container. Let's explore some reasons we might want that:

Component library

We probably don't want to fundamentally change the way our re-usable components are structured just for one particular menu. Let's say our component library looks more like this:

Open in Eraser

In this case, having a parent imperatively declare what goes in the Dropdown  might be wiser than building out components like Group or a dynamic ActionItem  that can either render a single Option  or be a menu container.

Availability changes more than rendering

With toolbars and other heavily context-dependent list of actions, the availability of some actions will often change as the user selects different items. If our actions are still grouped consistently when available and are consistently either in a sub-menu or not (or if we have no such menus), then it's often nice to keep the WYSIWYG nature of imperative rendering, and we can do so without ending up with conditional a ball-of-yarn result.

TL;DR

Lists of actions are an interesting problem because they can range dramatically from your simple "Edit / Delete" dropdown to deeply-nested, all-encompassing menus and toolbars. As always, the choices we make are shaped by concrete product considerations. But, as is often the case, it is also affected by the toolkit we have at our disposal - in this case, our UI framework and component library (hopefully we have one!)

Imperative approaches are better with simpler menus or where we need to have a lot of control over how things are rendered.

Registering your actions to a centralized location scales better to long lists of actions and to situations where we need to share logic around whether an action is available or disabled with other parts of our UI.

We can pair registration with a more configuration-driven rendering pattern in which our components build in consistent rendering logic, or we can go with a hybrid approach in which we maintain imperative control over rendering while still using the registration pattern to figure out which actions to show.

Open in Eraser