Props
Providers are constructed once, when the module opens. React props change on every render. The bridge between the two is PropsRef - a small box the module keeps up to date, and services read at their own pace.
The PropsRef Bridge
PropsRef
Every prop a module component receives - except children - flows into a PropsRef registered on the module:
import { PropsRef, createModuleComponent } from "@remodulo/react"
type OrderProps = { orderId: string }
const OrderModule = createModuleComponent<OrderProps>({ providers: [OrderStore] })
class OrderStore {
private readonly props = inject(PropsRef<OrderProps>)
order: Order | null = null
async refresh(): Promise<void> {
this.order = await fetchOrder(this.props.current.orderId)
}
}<OrderModule orderId="o-42">
<OrderView />
</OrderModule>current is always the latest props. Render <OrderModule orderId="o-7"> and every service reading props.current.orderId sees "o-7" - no re-construction, no re-registration, the same instances keep working.
Custom PropsRef
The default token works, but the read needs a type parameter - and any module's bridge above can answer it, because every unnamed bridge registers under the same PropsRef class. A subclass fixes both:
class OrderPropsRef extends PropsRef<OrderProps> {
get orderId(): string {
return this.current.orderId
}
}
const OrderModule = createModuleComponent<OrderProps>(
{ providers: [OrderStore] },
{ token: OrderPropsRef }
)
class OrderStore {
private readonly props = inject(OrderPropsRef)
order: Order | null = null
async refresh(): Promise<void> {
this.order = await fetchOrder(this.props.orderId)
}
}The subclass is the token and the type in one line. inject(OrderPropsRef) comes back fully typed, and a distinct class is a distinct token - it can never collide with another module's props, however many modules sit in between.
A props class has two constraints: no constructor parameters of its own - the bridge is the only thing constructing it - and no inject() inside, because it is built on the React side, outside any injection frame. Anything else is fair game: the bridge constructs your class itself, so a getter like orderId above is really there - instanceof OrderPropsRef holds, and services read named accessors instead of digging through current.
Usage with Modules
createModuleComponent
The factory wires the bridge for you - and the config can be a function of props:
type OrderContext = OrderProps & { workspaceId: string }
function useOrderContext(props: OrderProps): OrderContext {
const { workspaceId } = useWorkspace()
return { ...props, workspaceId }
}
class OrderPropsRef extends PropsRef<OrderContext> {}
const OrderModule = createModuleComponent<OrderProps, OrderContext>(
(props) => ({
providers: [OrderStore],
deps: [props.workspaceId],
}),
{ use: useOrderContext, token: OrderPropsRef}
)The config function is birth configuration. It runs on every render, but its result is honoured only when the module is created - and again when it is rebuilt. A changed prop does nothing to this channel by itself.
deps is what declares a rebuild. Element-wise Object.is against the previous render, the same rule useEffect uses. A change destroys the module and builds it again: new scope, new instances. Here a new workspaceId means a different order module entirely - so it rebuilds.
Rebuild is expensive - pick deps accordingly
Put a value that changes on every keystroke into deps and the module dies and is reborn on every keystroke, taking every subscription, timer, and in-flight request with it. Nothing will warn you: it will only be slow, and it will lose state.
use enriches the props before anything else sees them. It is a custom hook - it runs on every render and may call any other hook. Its result feeds both the bridge and the config function, which is why the config can read props.workspaceId although no parent ever passed one. Two rules: give it a name starting with use (the lint rules recognise hooks by name), and pass the same function every time - its identity is captured once, when createModuleComponent runs. The config function has the opposite rule: no hooks inside it.
ModuleProvider
The manual form of the same bridge. usePropsRef hands back the ref and a ready provider for it - you register the provider yourself:
import { ModuleProvider, usePropsRef } from "@remodulo/react"
function OrderBoundary({ orderId, children }: OrderProps & { children?: ReactNode }) {
const { provider } = usePropsRef<OrderProps>({ orderId }, { token: OrderPropsRef })
return (
<ModuleProvider providers={[provider, OrderStore]}>
{children}
</ModuleProvider>
)
}This is the factory, expanded. When createModuleComponent cannot express something - a conditional provider list, a second bridge, a module built from something that is not props - drop down to ModuleProvider and usePropsRef rather than looking for a factory option that will not be there.
Reacting to Changes
current is a pull - a service reads it when it needs it. For a push, subscribe:
class OrderStore {
private readonly props = inject(OrderPropsRef)
private release: (() => void) | null = null
order: Order | null = null
onModuleMount(): void {
this.release = this.props.onUpdate(
({ orderId }) => void this.load(orderId),
{ immediate: true }
)
}
onModuleUnmount(): void {
this.release?.()
this.release = null
}
private async load(orderId: string): Promise<void> {
this.order = await fetchOrder(orderId)
}
}immediate: true fires the listener once, right away, with the current value. The first load and every later prop change go down the same code path - no separate "initial fetch" branch.
The subscription is a resource. Taken in onModuleMount, released in onModuleUnmount, like every other acquisition. Reading current in a constructor is fine - that is just memory - but subscribing is not.
Updates land in a layout effect. By the time any effect below runs, current is fresh - and a subscriber never sees a value from a render React threw away.
Equal props are not an update. The ref compares incoming props shallowly against the last ones; nothing changed means nobody is notified.
Adapters
adapter is a pure transform that lives inside the ref: create(initial) runs at construction, update({ current, next }) on every change, and its output is what current returns. No hooks, no side effects.
import type { PropsAdapter } from "@remodulo/react"
// Keeps one stable object and mutates it in place - object identity survives updates
const stableProps: PropsAdapter<OrderProps> = {
create: (initial) => ({ ...initial }),
update: ({ current, next }) => Object.assign(current, next),
}
const OrderModule = createModuleComponent<OrderProps>(
{ providers: [OrderStore] },
{ token: OrderPropsRef, adapter: stableProps }
)The main use is handing props to a reactivity layer: an adapter can make current an observable object, so a computed that reads props.current.orderId re-runs when the parent passes a new one.
Swapping adapters is supported: the new adapter rebuilds the exposed value from the untouched raw props, so an adapter never wraps another adapter's output.