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.
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:
Simply set the
displayName
property on your generated component, like any other React component:const MyComponent = ModularComponent()
.with(...)
MyComponent.displayName = 'MyComponent'Pass your display name as first and only parameter to the initial
ModularComponent
call:const MyComponent = ModularComponent('MyComponent')
.with(...)Call
withDisplayName
somewhere in your pipeline:const MyComponent = ModularComponent()
.withDisplayName('MyComponent')
.with(...)