Writing Components
All our guides are written in TypeScript, as ModularComponent
was built from the ground up with TypeScript in mind.
However, it is perfectly possible to take advantage of ModularComponent
with standard JavaScript.
Configuring the component
At it simplest, a component is created by calling the factory:
import { ModularComponent } from './modular-component'
export const MyComponent = ModularComponent()
This is enough to get a valid, instantiable component. However, this component will do nothing: it does not handle
any state, and renders null
by default.
Component display name
One caveat of working with ModularComponent
is that React cannot infer its display name from the variable it is assigned too,
because the actual component is created inside the factory. This can make debugging trickier, as stack traces and React Devtools
will show all components as anonymous components.
You can get around this limitation by manually providing an (optional) display name at component creation:
import { ModularComponent } from './modular-component'
export const MyComponent = ModularComponent('MyComponent')
It is a good practice to keep the debug name and the variable name in sync.
Component properties
When using TypeScript, you can pass a generic type parameter to the ModularComponent
call to set the component's props:
import { ModularComponent } from './modular-component'
export const MyComponent = ModularComponent<{
isActive: boolean
label: string
}>()
The props typing will be passed along in each stage, and will also be used for the final typing of the component itself, so that TypeScript knows about them when instantiating it.
Adding stages
Now that our component is created, we can add stages to it to extend its capabilities. The result of the factory is a usable React FunctionComponent
,
on which custom factory methods have been added. To add a stage to our component, we can use the .with()
method.
Note that component factories are immutable. Because of this, you need to chain the stage calls in the same assignment as your component creation. You cannot call them as side-effects.
// ⚠️ This will not work - `with(stage)` call returns a modified
// component but does not touch the original one
const MyComponent = ModularComponent()
MyComponent.with(stage())
// ✅ Do this instead - save the result of the `with(stage)` call
// as your component
const MyComponent = ModularComponent()
.with(stage())
This will come in very handy in the next chapter about extending and reusing components, as well as for testing components
Custom stages
The .with()
method accepts a standard object comprised of two fields:
field
: the name of the argument that will get added to the argument mapuseStage
: a hook that receives the current argument map and returns the value to set on the stage field
While it's possible to use those objects directly when calling .with()
, for readability and ease of writing we
recommend creating custom stage functions that take relevant parameters and abstract away the stage logic.
All our official extensions are actually this kind of functions.
When calling .with()
, each stage will add or modify
a field on the shared argument map, consolidating data that can be consumed by further stages.
The order of stages is therefore important: only data from stages appearing higher in the list will be available in any given stage.
Generic stages (handling default props, injecting localization data...) should come first in the pipeline. If needed, we can then add a lifecycle stage handling the component's logic. Our component could look something like that:
const MyComponent = ModularComponent()
.with(Stage.globalStore())
.with(Stage.locale('localization.key.for.my.component'))
.with(Stage.lifecycle(({ locale, store }) => {
useDocumentTitle(locale('title'))
const someStoreValue = store.useState((store) => store.some.value)
const [someInternalValue] = React.useState('value')
return { someStoreValue, someInternalValue }
}))
Render stage
Finally, we can close our component by adding a render
stage. Stages setting the render
field are special
because the factory will look for it internally. As such, any stage downstream of a render
stage will be ignored
when rendering as a component.
Render stages need to return a valid React node, as their return will be used as the return value of the complete component. This is enforced internally at the TypeScript level.
Building on top of our previous example, this is what our component could look like:
const MyComponent = ModularComponent()
.with(Stage.globalStore())
.with(Stage.locale('localization.key.for.my.component'))
.with(Stage.lifecycle(({ locale, store }) => {
useDocumentTitle(locale('title'))
const someStoreValue = store.useState((store) => store.some.value)
const [someInternalValue] = React.useState('value')
return { someStoreValue, someInternalValue }
}))
.with(Stage.render(({ locale, lifecycle }) => (
<>
<h1>{locale('title')}</h1>
<p>{locale('content')}</p>
<p>Value from store: {lifecycle.someStoreValue}</p>
<p>Value from state: {lifecycle.someInternalValue}</p>
</>
)))