Writing Custom Stages
This guide describes how to write custom stage function providers, such as those provided by our official extensions.
Use your own custom stages to personalize your ModularComponent experience and tailor
it to your application (inject services, access a global store, use your internationalization solution...).
Stage function shape
A stage function is a function that can be passed to the .with() method
of a ModularComponent.
It needs the following signature:
(ctx?: Context): {
field: Field,
provide: (args: GetArgsFor<Context, Field>) => Type
}
The optional ctx parameter is there so that TypeScript
can infer its value from the ModularComponent to which
the function is passed. This is the key to being able to
infer the arguments received by the provide function, and
therefore its own return type.
It is marked optional because the factory will never actually set that parameter, it's only there to pass down the arguments type information.
The returned object describes how the stage will modify the arguments map:
field is the property that will be added or modified on the arguments map,
while provide is a function that takes the current arguments map up to that stage,
and returns the value to store in field.
The addTo helper
The @modular-component/core/extend subpath exports an addTo function aimed
at helping build stage functions.
It works as a chain of three functions: addTo returns an on function to specify the field (and infer its type from the provided value),
which in turn returns a provide function to set the arguments transformer, pre-configured to receive the correct arguments
for the field, and infers its return type.
The addTo function takes the current context as a generic TypeScript type parameter.
const stage = addTo<Context>()
.on('field')
.provide((args) => /* compute field value from args */)
Creating a stage function provider
In order to automatically infer the Context type and allow providing custom arguments usable inside the use transformer function,
we will wrap our stage definition inside a provider function.
import { ModularContext, addTo } from '@modular-component/core/extend'
function stage<Context extends ModularContext>(...params: any[]) {
return addTo<Context>()
.on('field')
.provide((args) => /* compute field value from args and params */)
}
Providing parameters
You are free to provide as many parameters as required for your stage to make sense. For instance, a localization stage can receive a key prefix to use in a translation hook, providing a locale getter scoped to that prefix.
We can leverage our provider function to infer and narrow the type of parameters through generic type parameters:
import { ModularContext, addTo } from '@modular-component/core/extend'
import { AvailablePrefixes, GetterFromPrefix, useTranslation } from './locale-system'
function locale<
Context extends ModularContext,
KeyPrefix extends AvailablePrefixes
>(keyPrefix: KeyPrefix) {
return addTo<Context>()
.on('locale')
.provide((args): GetterFromPrefix<KeyPrefix> => useTranslation(keyPrefix))
}
Making the field dynamic
Since the field is provided inside our provider function, it also becomes possible to infer it from a parameter. This allows creating dynamic stage providers that let the consumer choose the field to populate:
import { ModularContext, addTo } from '@modular-component/core/extend'
function stage<
Context extends ModularContext,
Field extends string,
Value
>(field: Field, value: Value) {
return addTo<Context>()
.on(field)
.provide(() => value)
}
Exposing arguments to the consumer
It can be useful to let the consumer access the arguments provided by upstream stages to compute their own value. This is the case for the render stage or the lifecycle extension for instance.
The arguments are provided by the provide function, but can be inferred from the Context directly in a parameter thanks to
helper types exported by @modular-component/core/extend: GetArgsFor and GetValueGetterFor.
GetArgsFor
The first way to expose arguments is to use the GetArgsFor helper type. This type is a generic type depending on Context and Field.
It returns the arguments available for that Field, keeping track of it internally to provide the correct typing even when the
field is overridden later by calling the same stage function again (see Reusing components).
You can use it to create a parameter taking a function computing a value from those args, and then call that function in
the provide transformer of your stage:
import { ModularContext, GetArgsFor, addTo } from '@modular-component/core/extend'
function stage<
Context extends ModularContext,
Field extends string,
Value
>(field: Field, useComputeValue: (args: GetArgsFor<Context, Field>) => Value) {
return addTo<Context>()
.on(field)
.provide((args) => useComputeValue(args))
}
GetValueGetterFor and wrap (recommended)
Rather than forcing your users to provide a function each time, you can opt for letting them choose between a function
or a direct value through the GetValueGetterFor. This type takes the Context and Field and a third Type parameter (which can be generic too!)
and creates a type accepting either a raw value of type Type, or a function of type (args: GetArgsFor<Context, Field>) => Type.
In order to safely use that value with the provided args object, you can use the wrap helper provided by @modular-component/core/extend to
wrap a raw value inside a function:
import { ModularContext, GetValueGetterFor, addTo, wrap } from '@modular-component/core/extend'
function stage<
Context extends ModularContext,
Field extends string,
Value
>(field: Field, useComputeValue: GetValueGetterFor<Context, Field, Value>) {
return addTo<Context>()
.on(field)
.provide((args) => wrap(useComputeValue)(args))
}
Constraining the return type
Since fields can be overridden by providing the same stage function later down the chain (see Reusing components),
you will want to restrict the type of your return values to match what was previously provided. You can do that through the GetConstraintFor
type provided by @modular-component/core/extend.
The GetConstraintFor type accepts three generic values: Context, Field, and an optional Default value to restrict
the value provided the very first time.
For instance, our lifecycle extension makes sure the value returned
from a lifecycle stage starts as an object, and if overridden, respects the contract set up by previous calls:
export function lifecycle<
Context extends ModularContext,
Type extends GetConstraintFor<Context, 'lifecycle', {}>,
>(useLifecycle: GetValueGetterFor<Context, 'lifecycle', Type>) {
return addTo<Context>().on('lifecycle').provide(wrap(useLifecycle))
}
You can also use GetConstraintFor in more complex types, such as this version of our locale stage from earlier
which makes sure any further calls are limited to key prefixes containing the same sub-values as the previously provided one:
import { ModularContext, addTo } from '@modular-component/core/extend'
import { AvailablePrefixes, GetterFromPrefix, useTranslation } from './locale-system'
type KeyPrefixConstraint<Context extends ModularContext> = {
[prefix in AvailablePrefixes]: GetterFromPrefix<prefix> extends GetConstraintFor<Context, 'locale'>
? prefix : never
}[AvailablePrefixes]
export function locale<
Context extends ModularContext,
KeyPrefix extends KeyPrefixConstraint<Context>,
>(keyPrefix: KeyPrefix) {
return addTo<Context>()
.on('locale')
.provide(
(): GetterFromPrefix<KeyPrefix> =>
useTranslation(keyPrefix),
)
}
Providing a type for registering the stage
Since a stage provider function can either be used with the .with() method, or registered as its own with<Stage> method,
you will want to provide type information for the registration.
This can be easily achieved through two helpers types provided by @modular-component/core/extend: StageParams and StageReturn.
Since our stages often take generic parameters, it is not possible to have a single helper type that infers the complete registration type from your function. You will still need to manually create a type where your generics are provided:
import { ModularContext, GetValueGetterFor, GetConstraintFor, StageParams, StageReturn, addTo, wrap } from '@modular-component/core/extend'
import { AvailablePrefixes, GetterFromPrefix, useTranslation } from './locale-system'
type KeyPrefixConstraint<Context extends ModularContext> = {
[prefix in AvailablePrefixes]: GetterFromPrefix<prefix> extends GetConstraintFor<Context, 'locale'>
? prefix : never
}[AvailablePrefixes]
export function locale<
Context extends ModularContext,
KeyPrefix extends KeyPrefixConstraint<Context>,
>(keyPrefix: KeyPrefix) {
return addTo<Context>()
.on('locale')
.provide(
(): GetterFromPrefix<KeyPrefix> =>
useTranslation(keyPrefix),
)
}
export type WithLocale<
Context extends ModularContext
> = <
KeyPrefix extends KeyPrefixConstraint<Context>,
>(
...args: StageParams<typeof locale<Context, KeyPrefix>>
) => StageReturn<typeof locale<Context, KeyPrefix>>
export function stage<
Context extends ModularContext,
Field extends string,
Value
>(field: Field, useComputeValue: GetValueGetterFor<Context, Field, Value>) {
return addTo<Context>()
.on(field)
.provide((args) => wrap(useComputeValue)(args))
}
export type WithStage<
Context extends ModularContext
> = <
Field extends string,
Value
>(
...args: StageParams<typeof stage<Context, Field, Value>>
) => StageReturn<typeof stage<Context, Field, Value>>
You can see the parallels between the function and the type definition: we find the same generic types, except for Context
which is provided through the type directly. All other generics must be defined as generics of the returned function to
be inferred through the given parameters.
Registering the stage
You can register your custom stages to make them readily available on ModularComponents without needing
to import them. Registering a stage stage will add a new .withStage method to your ModularComponents.
Registering the stage is a two-steps process.
Registering the runtime implementation
You can register stages by calling the ModularComponent.register() function. Calling register() multiple times do not replace the registered stages, instead it merges them.
import { ModularComponent } from '@modular-component/core'
import { locale, stage } from './custom-stages'
ModularComponent.register({ locale, stage })
Make sure to import the module where your register runtime implementations before any module using ModularComponent,
so that the stages are actually made available.
You can optionally re-export ModularComponent from a module doing the registration, and always use this import
in your app.
Registering the typing information
You can use the types created above to register your stages typing information so that the TypeScript compiler becomes aware of them.
Simply redeclare @modular-component/stages to extend the ModularComponentStages interface
with your stages:
import type { WithLocale, WithStage } from './custom-stages'
declare module '@modular-component/stages' {
export interface ModularComponentStages<Context extends ModularContext> {
withLocale: WithLocale<Context>
withStage: WithStage<Context>
}
}
In a TypeScript project, the ModularComponent.register() function will throw an error
if you pass a stage that was not added to the ModularComponentStages interface.
Putting it together
Here is a full example of a "define -> register -> use" loop, for a stage that injects an analytics tracker. The tracker itself lives outside the component, so opting into tracking is now a one-line action in each component.
export const analyticsTracker = {
track({ component, event, props, extra }: {
component: string
event: string
props: Record<string, unknown>
extra?: Record<string, unknown>
}) {
console.log('[track]', component, event, props, extra)
},
}
import { ModularComponent, ModularContext } from '@modular-component/core'
import { addTo, StageParams, StageReturn } from '@modular-component/core/extend'
import { analyticsTracker } from '../services/analytics'
// Definition
function tracking<Context extends ModularContext>(
componentName: string,
) {
return addTo<Context>()
.on('tracking')
.provide(({ props }) => ({
track(event: string, extra?: Record<string, unknown>) {
analyticsTracker.track({
component: componentName,
event,
props,
extra,
})
},
}))
}
type WithTracking<Context extends ModularContext> = (
...args: StageParams<typeof tracking<Context>>
) => StageReturn<typeof tracking<Context>>
// Registration
ModularComponent.register({ tracking })
declare module '@modular-component/stages' {
interface ModularComponentStages<Context extends ModularContext> {
withTracking: WithTracking<Context>
}
}
The registration makes .withTracking available everywhere. A component only has to provide its label once;
the tracker is injected through the stage so consumers never import it directly.
import { ModularComponent } from '@modular-component/core'
export const TrackedButton = ModularComponent<{ label: string }>()
.withTracking('TrackedButton')
.withRender(({ props, tracking }) => {
tracking.track('rendered')
return <button>{props.label}</button>
})
The tracking argument is available for all stages downstream from withTracking, making it easy
to track actions within that component.