Skip to main content

ModularComponent

🍞.with(🍅).with(🧀) = 🥪

Delightfully organized and deeply testable React Components

What are Modular Components

Modular Components are React components built through a modular factory, allowing the addition of functionality as needed as the component is built.

The modular factory approach is built on top of React hooks, and offers out-of-the-box composition for building and stub injections for testing.

It offers best-in-class DX through strong typing and easy separation of concern, and is infinitely extensible thanks to its built-in modular nature.

You can think of it as "higher-order hooks", without the awkward chaining of methods thanks to the factory pattern.

Installation

At it simplest, you will need the @modular-component/core package to get started with ModularComponent.

However, the core module alone does not bring any component capability - it needs plugins, or extensions, to start shining ✨ !

We provide a sensible set of default capabilities through the @modular-component/default package. It is highly recommended to install both for getting started.

yarn add @modular-component/core @modular-component/default

You can then build your components using the ModularComponent factory exported from @modular-component/core, and the default set of stages exported from @modular-component/default.

import { ModularComponent } from '@modular-component/core'
import * as Stage from '@modular-component/default'

const MyFirstModularComponent = ModularComponent<{
someFlag?: boolean
someLabel: string
someValue: number
}>()
.with(Stage.defaultProps({ someFlag: false }))
.with(Stage.lifecycle(({ props }) => {
const [someState, setSomeState] = useState(0)

return { someState }
}))
.with(Stage.render(({ props, lifecycle }) => (
<>
<h2>
{props.someLabel}: {props.someValue}
</h2>
<p>Value from state: {lifecycle.someState}</p>
<p>Flag from props: {props.someFlag ? 'true' : 'false'}</p>
</>
)))

How it works

The factory pipeline

Pipeline stages

The main concept behind the ModularComponent approach is the factory pipeline.

At its core, a ModularComponent is a set of ordered stages, each of which populates a specific argument in a shared object, which gets passed from stage to stage.

The last stage (the render stage) has therefore access to data computed by every previous stage in the pipeline.

This for instance allows separating any stateful lifecycle computation in a dedicated stage, and keep the render stage for its main purpose: laying down the markup through JSX.

Here is the "getting started" example, complete with comments explaining the pipeline system:

import { ModularComponent } from '@modular-component/core'
import * as Stage from '@modular-component/default'

const MyFirstModularComponent = ModularComponent<{
someFlag?: boolean
someLabel: string
someValue: number
}>()
// .with(defaultProps) modifies the `props` argument to mark
// provided props as NonNullable. Here, `someFlag` will be
// a `boolean` for all downstream stages, instead of `boolean | undefined`
// as it originally was
.with(Stage.defaultProps({ someFlag: false }))
// .with(lifecycle) receives the modified props from .with(defaultProps)
// It then uses React hooks to construct our component's internal state
.with(Stage.lifecycle(({ props }) => {
const [someState, setSomeState] = useState(0)

return { someState }
}))
// Finally, .with(render) receives both the up-to-date props and the new
// lifecycle argument generated by the .with(lifecycle) stage
.with(render(({ props, lifecycle }) => (
<>
<h2>
{props.someLabel}: {props.someValue}
</h2>
<p>Value from state: {lifecycle.someState}</p>
<p>Flag from props: {props.someFlag ? 'true' : 'false'}</p>
</>
)))

Adding stages

You can keep on chaining .with calls as much as you want to add more stages. However, a given field can only be set once: calling .with again with a different payload for the same field will replace the initial stage set for that field.

Internally, a ModularComponent keeps an array of ordered stages. Calling the .with method will either append a new stage to the array, or edit an already existing value.

When using TypeScript, the compiler will tell you if a subsequent call to .with with an existing field causes a conflict with the previous value, to ensure coherence between stages.

TypeScript: Forcing a new value on a field

If you are using TypeScript and want to override a field completely without backwards compatibility with its previous value (for instance if you know you will be editing all stages depending on the field too), you can replace .with with .force, which does the same internally but bypasses the type checking.

Generating hooks

By default, if a render stage is not provided in your pipeline, ModularComponent will inject one on your behalf that returns null, so that your component stays renderable.

But you can also opt to skip the render step entirely, turning your ModularComponent into a hook.

Simply append use() at the end of your chain to enable this feature:

const useModularHook = ModularComponent()
.with(Stage.lifecycle(() => {
const [enabled, setEnabled] = React.useState(false)
const toggleOn = React.useCallback(() => setEnabled(true), [])
const toggleOff = React.useCallback(() => setEnabled(false), [])
return { enabled, toggleOn, toggleOff }
}))
.use()

// ...
const { lifecycle } = useModularHook()

lifecycle.toggleOn()
lifecycle.enabled

You can also pass the name of an argument field to the use() method to isolate this specific stage. Stages downstream to the stage generating the argument will be skipped, and the return value will be scoped to the selected field:

const useIsolatedStage = ModularComponent()
.with(Stage.lifecycle(() => {
const [enabled, setEnabled] = React.useState(false)
const toggleOn = React.useCallback(() => setEnabled(true), [])
const toggleOff = React.useCallback(() => setEnabled(false), [])
return { enabled, toggleOn, toggleOff }
}))
.use('lifecycle')

// ...
const { toggleOn, toggleOff, enabled } = useIsolatedStage()

Other methods

Isolating a stage

For testing purposes, you might want to extract a stage function to be able to unit-test it in isolation. You can use the stage() method to achieve just that.

stage() takes things a step further than use(), and should really only be used for testing purposes. It always takes a field name to isolate, and returns a hook that takes the arguments map as parameter rather than the component props.

When using stage(), you are responsible for providing the relevant arguments expected by your stage function to work. Its only use-case is for testing purposes where you want to finely control each input and check the output.

Setting the component's displayName

Since the actual component function is created inside the factory, React cannot infer its display name from your variable name. If you want to set a display name for debugging purposes, you have several ways:

  1. Simply set the displayName property on your generated component, like any other React component:

    const MyComponent = ModularComponent()
    .with(...)

    MyComponent.displayName = 'MyComponent'
  2. Pass your display name as first and only parameter to the initial ModularComponent call:

    const MyComponent = ModularComponent('MyComponent')
    .with(...)
  3. Call withDisplayName somewhere in your pipeline:

    const MyComponent = ModularComponent()
    .withDisplayName('MyComponent')
    .with(...)