In this article, we’ll talk about the architecture of Reclame, our React component library.
20/02/2022
At La Javaness, we develop tailor-made UIs for our clients, which sometimes need to blend in with the visual style and behaviour of clients’ existing UIs. For that reason, we must be able to easily make variants of a component, with slight behaviour or content changes. We also work a lot with public service clients, so our component library must be fully compliant with RGAA, France’s digital accessibility standard.
Besides, we’ve historically struggled to provide consistent interaction patterns, state management and APIs between components before the introduction of a shared library, so we want the component architecture itself to encourage commonality between components. That commonality also supports another goal: maintainability. By decreasing code duplication and more tightly separating concerns, we make it easier to fix bugs on a specific layer of our components and to keep behavioural changes and fixes in sync across all components.
From there derive Reclame’s foundational principles:
Modularity: it should be fast and efficient to specialise an existing component for a new edge case
Maintainability: fixing a bug or adding a feature should be repetition-free
Accessibility: all users, regardless of aptitude, should succeed in using Reclame-based UIs
Separation of Concerns: layout, style, behaviour and state are written in isolated layers to help their understandability and maintainability
Reclame is a library of React function components. Our components expose their own props API, and mix it together with APIs provided by generic features, automagically through our useReclamehook. The feature mixing mechanism allows us to add, alter or edit behaviour on a component, and also to reuse behavioural logic across components. This is what makes Reclame truly unique!
Table of Contents
Basic Concepts
Component Anatomy
Features
Feature Mixing
Conclusion
Basic Concepts
File Structure
Components are organised in families of variants, stored in one folder:
__features__ contains feature files (more on that later)
__stories contains one Storybook doc file per variant
__tests__ contains a unit test suite, and for each component, a runner using a subset of the test suite with relevant props
partials.jsx contains the code shared by all variants in the component family
each variant is written in a separate jsx file
An Example
Let’s look at a simplified version of Button as an example:
This is unusually short. As you can see, most of the actual button behaviour isn’t defined here. In a component file, we merely call the useReclame hook to initialise features, and then assemble DOM elements.
Let’s now give a basic intro on the core mechanisms of Reclame: partials, params, and the useReclame hook.
Component Partials
The partials file that provides ButtonBase and renderRootProps is where most of the component is defined. This allows us to reuse those definitions for Button, FormButton, OverlayButton, SplitButton and ToggleButton. Partials files contain a *BaseAPI for a component family and rendering partials that provide computed props for a given DOM element that’s expected to be found in the component family. Here, we use ButtonBase as we’re making a button. The renderRootProps function also returns props for buttons, such as onClick.
To go further: If we didn’t use the Reclame architecture, we’d need to write a giant Button component that supports many features, some of which aren’t compatible with each other, which would result in much increased complexity for developers browsing the documentation, and increased combinatorial complexity. By exposing several simpler APIs rather than one complex one, we implement the interface segregation principle in our UI component library, as we end up with many simple and specialised components instead of a single complex one.
Component Variants
Components are organised by families, based on similarity. What are the distinction criteria between variants of the same component family and components from different families?
Two components could be considered different if:
They don’t serve the same purpose: eg. a TextField and a Button have nothing in common
They have radically different APIs: eg. an Icon and an Image both display visual content, but the icon displays a named symbol with a predictable style, whilst the image refers to unique content of varying dimensions
They have radically different implementations; variants share parts of their props, features, rendering logic and tests, so if there is no code in common between two components, they aren’t variants of one another
Components could be variants of an original component if they meet one or several of the following conditions:
They constrain the API of the original component
They add (or remove) a feature of the component
They change the rendering order or style of the component
They change default props for a component
They combine the features of two other variants
They’re similar but meant for a different use case and may change accordingly in the future, eg. Heading vs the base Text component; or BackgroundImage which doesn’t qualify as content like Image does
Component Anatomy
Time to discuss what makes up a Reclame component. The useReclame hook provides a params object which holds the props and state consumed by the component’s main render function and any third-party renderers it uses.
The useReclame Hook
The useReclame hook is always the first thing we call in a Reclame component. It does the following:
Class init: On the first component instantiation, it composes the ButtonBase feature with any other declared features into a single API with its propTypes
Prop reconciliation: On each instantiation, it injects every feature’s defaultProps into the instance’s props, and filters out unrecognised props into a restProps object
Feature init hooks: On each instantiation, it runs feature initialisation logic (which may affect state or prop values depending on the feature)
Named state: On each instantiation, the init hook of stateful features returns a state object; the useReclame hook provides these feature states in its return value, as explained in the next section
The params Object
useReclame returns an object containing four properties:
bem: an object with utility functions to generate blockand elementBEM CSS class names, derived from @bem-react/classname
props: the instance’s props, with default values injected where needed
state: a space to store state for the instance
features: an object where each key is a feature ID and each value is the state of the corresponding feature
We call this object params. When we need a property inside it, we destructure it. Every Reclame renderer function — and most of our internal hooks and utility functions — directly accept the params object, which helps you abstract away those functions’ implementation details.
To go further: Why not destructure props directly? With direct destructuring, you’d need to repeat the name of every prop into every renderer function. It makes maintenance just as repetitive — and error-prone, as whenever you edit a prop, you must apply the edit to a variety of files. Alternatively, give only the needed props to each renderer function; this breaks encapsulation and forces you to know the implementation details of third-party code which you should be able to treat as a black box. With params, you don’t have to remember which parts of your component API will be used by which renderer, and you can focus on one task at a time.
Renderers
The rendering functions are called renderers. They come in two forms. The first type take the props object and return well-formatted props for a DOM element; they are named render*Props by convention. We often use a render*Props for the root element of a component because all variants of the component are likely to need the same props rendered in their root.
The second type return a JSX element to include in a virtual DOM, and are named renderFoo, renderBar, etc., by convention. These allow us to share recurring elements between component variants. The example below renders an element for Field labels, which informs users that the Field is required. It is used in six components.
The Main Render Function
The render function of a component must always call useReclame first. It must return a DOM tree that can call any renderers it wants in any order. All renderers provided by features will accept the propsand state returned by useReclame, so you don’t have to think too hard about their API.
Style Management
Our component styles are managed in a SASS stack and linked to components with the BEM methodology. That’ll be a topic for another day.
Features
Reclame components can be extended by linking to features. Features provide additional code logic and state, and additional UI content. For instance, the ability to display file metadata in a FileLink is a feature. The ability to click a component or to focus it is a feature. And both the Clickable and Focusable features rely on Disableable, a third feature that provides the isDisabled prop and correctly generates the aria-disabled attribute so we don’t forget to add it to components that can be disabled.
Because useReclame mixes features and initialises them in their order of declaration, the feature construct is the primary mechanism for creating component variants and reusable code logic. There are roughly four types of features:
Shared features: they are used by several families of components, eg. Clickable or AriaLabelable
Component bases: they are the core logic for a family of components, such as Button or Field
Component features: they are used by some components in a family of features, but not all; eg. OverlayButton and SplitButton share an overlay feature; those tend to be transformed into shared features as they mature
Anonymous features: they’re made on the spot for a single component, and get mixed with the rest of its features in a predictable order
Feature Anatomy
Features are created by calling Reclame’s feature function. It returns a feature that can be injected into a component, out of a declaration with the following properties:
id: an identifier for the feature, used to retrieve its state
className: used by @bem-react/classname as a block CSS class for the component [only for base features]
modifiers: a function that computes BEM modifier CSS classes to apply to the component
propTypes: the feature’s API, expressed with prop-types
defaultProps: default values for the propTypes
propDescriptions: used for our Storybook prop tables because react-docgen can’t extract comments from dynamically composed propTypes
hook: a custom hook used to initialise the feature, which must return any state needed by the feature’s renderers
mixes: dependent features, which should be mixed into the feature being generated (eg. Disableable is mixed into Clickable and Focusable)
Besides the feature function, feature files may export renderer functions, eg. the file metadata feature:
Shared Feature Examples
Below are some shared features that are in wide use in Reclame. They demonstrate different benefits brought about by our architecture.
Dependency Inversion
The Translatable feature allows us to abstract away the i18n stack being used while writing component code. It also exposes the translation function to renderers in a partials file or feature file.
Consistent Prop and BEM Modifier APIs
VariantAble is injected into every Reclame component by default. It adds a variant prop that can either be a single style variant, or multiple ones. Because this feature defines a modifiers function, the appropriate BEM modifier CSS classes are automatically added to the root of the component.
Correct, Centralised Props Computation
The Focusablefeature demonstrates how the props function helps us keep the code DRY. It not only adds onFocus and onBlur, but also that pesky tabindex that developers often forget about, and it provides the disabled logic to the element receiving interactive ability. Features can define multiple props functions if they need to provide props to multiple elements.
Automated and Transparent State Management
The Closable feature lets components define a method to close or open some of their elements (or the whole component, as in Callout). It provides both a hook and a prop API, that work together to support a defaultClosed prop. There’s little risk of the props and hook code falling out of sync because they’re maintained together.
Besides, simply including the feature in a component’s useReclame call causes the hook to be called, which means even less work for developers. They can simply read the feature’s state when they need it.
Component Base Example
Component base features always include a className property, and often mix a lot of external features. Here, AvatarBase provides Avatar with the ability to be clicked, defined with different sizes or to have an optional ARIA label. The LoadableImage feature is mixed in so that Avatar can replace a missing image with the content of the shorthandprop by customising the onError prop it provides and installing it on the image element.è
Anonymous Feature Example
Finally, any state or extra props needed by one variant of a component family can also be provided in the form of anonymous features. This is relevant in the following situations:
If one needs to edit a prop provided by another component (eg. onClick is no longer mandatory on FormButton)
If one wants their feature hook to be called before an external feature, which can then be mixed after the anonymous feature
Feature Mixing
One of the most mysterious elements of our architecture is the mixes property of feature(), as well as the logic employed by useReclame to combine features. The mixer, as we call it, creates a new props API for the mixed features, and builds a list of the features in the order they were encountered (including when multiple mixes are nested). The list is instrumental in ensuring useReclame calls init hooks in the right order, to help preserve rules of hooks.
We won’t discuss hardcodedProps and prop erasure, for the sake of conciseness.
The mixer’s return value is itself a feature, with a unique id. When useReclame calls mix, it applies the result directly to the component function being initialised, but when adding dependencies to a feature, mix merely returns a new structure that ‘remembers’ the order in which features will later have to be combined by useReclame.
Conclusion
In this article, we presented the architecture behind our React components at La Javaness. This architecture is made of reusable features, component families that define a base behaviour with reusable and discretionary features, and individual components that custom their family’s base behaviour and render a DOM tree.
Component families and shared features provide us with a host of benefits.
They help us provide consistent experiences, where interactions such as clicking or closing behave the same on every component, with the same API exposed to frontend developers.
They increase code reuse, and avoid us having to modify dozens of components individually when we adjust props in a feature for accessibility compliance, for instance.
Reclame also demonstrates how architectural decisions in a frontend stack can support specific business interests. As our business model is similar to that of an agency, with one-off projects, we’re often confronted with clients that request behavioural or visual adjustments to our components. Reclame helps us define new variants of an existing component with minimal work, faster than if we had to integrate every request into a single code base.
As we’ve now demonstrated the fitness of our component architecture to our needs, we’re looking to expand on the concept of shared features to automate unit testing, and to build similar code reuse capabilities into our CSS stack. If you’d like to take on the challenge with us, feel free to get in touch.
Vous souhaitez en savoir plus sur notre entreprise, nos actualités, et d'autres sujets ?
Abonnez-vous à notre newsletter pour ne rien manquer !
Bình luận