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 theActionItem
component needed to know that an action might be disabled and rules around how to render it. In order to implement this requirement, theGroup
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.
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.
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:
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:
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.