• Steve

Reclame — A Modular UI Component Architecture

Dernière mise à jour : 16 mars


In this article, we’ll talk about the architecture of Reclame, our React component library.



20/02/2022

At La Javaness, we do agency work, and 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

  1. Basic Concepts

  2. Component Anatomy

  3. Features

  4. Feature Mixing

  5. 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:

  1. Class init: On the first component instantiation, it composes the ButtonBase feature with any other declared features into a single API with its propTypes

  2. 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

  3. Feature init hooks: On each instantiation, it runs feature initialisation logic (which may affect state or prop values depending on the feature)

  4. 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:

  1. If one needs to edit a prop provided by another component (eg. onClick is no longer mandatory on FormButton)

  2. 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 !



39 vues0 commentaire