Skip to content

Connecting Reactivity

Remodulo owns objects: when one is constructed, when it mounts, when it dies. What re-renders the view is your reactivity layer's job - and any layer works, if it can subscribe a component to an object.

One fact matters on every path below: useResolve is a read, not a subscription. It returns the same instance on every render and never re-renders anything on its own. The subscription is always yours, and it always has the same shape: the module owns the service, the view subscribes to it.

Pick your layer

Hand-rolled

No dependencies. The store keeps a listener set; React's own useSyncExternalStore reads it.

tsx
class BoardStore {
    private readonly listeners = new Set<() => void>()

    private tasks: readonly Task[] = []

    setTasks(tasks: readonly Task[]): void {
        this.tasks = tasks
        for (const listener of this.listeners) listener()
    }

    readonly subscribe = (listener: () => void): (() => void) => {
        this.listeners.add(listener)
        return () => void this.listeners.delete(listener)
    }

    readonly getSnapshot = (): readonly Task[] => this.tasks
}
tsx
import { useSyncExternalStore } from "react"
import { useResolve } from "@remodulo/react"

function BoardView(): ReactElement {
    const store = useResolve(BoardStore)
    const tasks = useSyncExternalStore(store.subscribe, store.getSnapshot)

    return (
        <ul>
            {tasks.map((task) => (
                <li key={task.id}>{task.title}</li>
            ))}
        </ul>
    )
}

The store is registered like any other service, and the boundary decides its lifetime:

tsx
import { createModuleComponent, withModule } from "@remodulo/react"

const BoardModule = createModuleComponent({ id: "board", providers: [BoardStore] })

export const Board = withModule(BoardModule, BoardView)

Every section below is the same two halves with a library in the middle.

MobX

Our recommendation, and what Getting Started uses. One line in the service, one wrapper on the view.

tsx
import { makeAutoObservable } from "mobx"

class BoardStore {
    tasks: readonly Task[] = []

    constructor() {
        makeAutoObservable(this)
    }

    setTasks(tasks: Task[]) {
        this.tasks = tasks
    }
}
tsx
import { useResolve } from "@remodulo/react"
import { observer } from "mobx-react-lite"

const BoardView = observer(function BoardView(): ReactElement {
    const store = useResolve(BoardStore)

    return (
        <ul>
            {store.tasks.map((task) => (
                <li key={task.id}>{task.title}</li>
            ))}
        </ul>
    )
})

Zustand

The same createStore you already write - created by the service instead of at file scope. One store per module, not one per process.

tsx
import { createStore } from "zustand/vanilla"

type BoardState = {
    tasks: readonly Task[]
    setTasks: (tasks: readonly Task[]) => void
}

class BoardStore {
    readonly state = createStore<BoardState>((set) => ({
        tasks: [],
        setTasks: (tasks) => set({ tasks }),
    }))
}
tsx
import { useStore } from "zustand"
import { useResolve } from "@remodulo/react"

function BoardView(): ReactElement {
    const store = useResolve(BoardStore)
    const tasks = useStore(store.state, (state) => state.tasks)

    return (
        <ul>
            {tasks.map((task) => (
                <li key={task.id}>{task.title}</li>
            ))}
        </ul>
    )
}

useStore takes the store as an argument. That is the whole trick: the hook does not need a module-level singleton, so the instance can come from the module. Selectors and middleware work unchanged.

Anything else

If your layer can subscribe a component to an object it did not create - it fits. The recipe never changes: service owns the state, view subscribes, module owns the service.

Where to go next

Guides

Reference

MIT licensed.