Skip to content

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

ts
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.

tsx
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.

tsx
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>

ts
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.

tsx
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.

Where to go next

Guides

Reference

MIT licensed.