PropsRef
PropsRef is what a module registers so its providers can read props that arrive after construction. A service reads current when it needs the props and subscribes with onUpdate when it needs a push. The semantics are on Props; this page is the API surface.
Signature
class PropsRef<T = any> {
constructor(config: { props: object; adapter?: PropsAdapter<any, T> })
get current(): T
onUpdate(cb: (next: T, prev: T) => void, options?: { immediate?: boolean }): () => void
update(next: object): void
setAdapter(adapter?: PropsAdapter<any, T>): void
}current and onUpdate are the service-side surface. update and setAdapter belong to the bridge - usePropsRef calls both; your code does not.
current
current is the adapter's output. Under the default adapter that is the props object React passed, unchanged.
Reading it is a pull. A service that only reads current is never notified of anything.
onUpdate
onUpdate returns the unsubscribe callback. Nothing else removes the listener - hold the returned function and call it when the subscription's owner goes away.
{ immediate: true } invokes the callback once, right away, with the current value as both next and prev. The first load and every later prop change go down the same path.
import { inject } from "@remodulo/container"
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)
}
}OrderStore takes the subscription in onModuleMount and releases it in onModuleUnmount. The subscription is a resource, acquired and released like any other.
Subclassing
The constructor takes one argument: { props, adapter }. A subclass keeps that shape, which in practice means it declares no constructor at all.
import { PropsRef } from "@remodulo/react"
type OrderProps = { orderId: string }
class OrderPropsRef extends PropsRef<OrderProps> {
get orderId(): string {
return this.current.orderId
}
}OrderPropsRef is the token and the type in one line. inject(OrderPropsRef) comes back typed, and a distinct class can never collide with another module's bridge.
Two rules for a props class:
- No constructor parameters of its own. The bridge constructs it with
{ props, adapter }and passes nothing else. - No
inject()inside. It is constructed on the React side, outside any construction frame:
inject(OrdersApi) was called outside a construction frame.Anything else is fair game. The bridge constructs your class itself, so instanceof OrderPropsRef holds and services read named accessors instead of digging through current.
PropsAdapter<P, T>
type PropsAdapter<P extends object, T = P> = {
create(initial: P): T
update(args: { current: T; next: P }): T
}The adapter is pure. create(initial) runs when the ref is constructed, update({ current, next }) on every change, and the returned value is what current hands back. No hooks, no side effects.
import type { PropsAdapter } from "@remodulo/react"
// One stable object, mutated in place - its identity survives every update.
const stableProps: PropsAdapter<OrderProps> = {
create: (initial) => ({ ...initial }),
update: ({ current, next }) => Object.assign(current, next),
}stableProps keeps one object and assigns into it, so a consumer that captured current keeps a live object rather than a stale snapshot.
Swapping the adapter rebuilds the exposed value from the raw props. The ref keeps the untouched props it was last given, so a new adapter wraps the source and never another adapter's output. The swap notifies subscribers.
Update timing
Updates land in a layout effect. The bridge writes the new props before any effect below it runs, so a subscriber never sees a value from a render React threw away.
Equal props are not an update. Incoming props are compared shallowly against the last ones; nothing changed means nobody is notified and current is left alone.
A throwing subscriber does not stop the others. The error is caught and logged as PropsRef.onUpdate: subscriber threw, and notification continues. The pass iterates a copy of the subscriber set, so a callback may subscribe or unsubscribe without disturbing the pass in flight.