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:
This article explores two approaches to implementing a dynamic list of actions for a UI that ends up something like this:
- Imperative container
- 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:
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
ActionItemcomponent needed to know that an action might be disabled and rules around how to render it. In order to implement this requirement, the
Groupcomponent 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
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.
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-onlymode 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.
This approach is also generally paired with shifting the knowledge hierarchy downward.
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.
Create from templategroup 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
unregisterto 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
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.
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
In that approach, we can simplify our
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:
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:
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.
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.
Exploring forks in the road of software design with a weekly newsletter.