Skip to main content

ModularComponent

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

Delightfully organized and deeply testable React Components

What are Modular Components

tip

Prefer the TL;DR? See Core Concepts.

Modular Components are React components built through a modular factory, allowing functionality to be added as the component is being 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 concerns, 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 minimum, 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.

npm install @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 { useState } from 'react'
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>
</>
)))

Alternatively, you can opt to register custom stage functions on the factory. This is the recommended way of using ModularComponent.

Extensions offer a /register entrypoint for that purpose:

import { useState } from 'react'
import { ModularComponent } from '@modular-component/core'
import '@modular-component/default/register'

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

return { someState }
})
.withRender(({ 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 (usually 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 '@modular-component/default/register'

const MyFirstModularComponent = ModularComponent<{
someFlag?: boolean
someLabel: string
someValue: number
}>()
// .withDefaultProps() 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
.withDefaultProps({ someFlag: false })
// .withLifecycle() receives the modified props from .withDefaultProps()
// It then uses React hooks to construct our component's internal state
.withLifecycle(({ props }) => {
const [someState, setSomeState] = useState(0)

return { someState }
})
// Finally, .withRender() receives both the up-to-date props and the new
// lifecycle argument generated by the .withLifecycle() stage
.withRender(({ 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/.with<Stage> calls as much as you want to add more stages. However, a given field can only be set once: calling .with/.with<Stage> again with a different payload for the same field will replace the initial stage set for that field.

When using TypeScript, the compiler will tell you if a subsequent call to .with/.with<Stage> 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<Stage> with .force/.force<Stage>, 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:

import { useState, useCallback } from 'react'
import { ModularComponent } from '@modular-component/core'

const useModularHook = ModularComponent()
.withLifecycle(() => {
const [enabled, setEnabled] = useState(false)
const toggleOn = useCallback(() => setEnabled(true), [])
const toggleOff = 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:

import { useState, useCallback } from 'react'
import { ModularComponent } from '@modular-component/core'

const useIsolatedStage = ModularComponent()
.withLifecycle(() => {
const [enabled, setEnabled] = useState(false)
const toggleOn = useCallback(() => setEnabled(true), [])
const toggleOff = 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.

tip

When calling stage('render'), stage will return a function component instead of a hook. In fact, calling stage(field) on any field returning a React node will create a function component instead of a hook.

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(...)