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 - zero dependencies, React's own
useSyncExternalStore. - MobX - our recommendation, and what Getting Started uses.
- Zustand - the vanilla stores you already write, one per module.
- Anything else - the one test your layer has to pass.
Hand-rolled
No dependencies. The store keeps a listener set; React's own useSyncExternalStore reads it.
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
}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:
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.
import { makeAutoObservable } from "mobx"
class BoardStore {
tasks: readonly Task[] = []
constructor() {
makeAutoObservable(this)
}
setTasks(tasks: Task[]) {
this.tasks = tasks
}
}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.
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 }),
}))
}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.