Getting Started
Install
npm install @remodulo/container @remodulo/reactPeer dependency
@remodulo/react expects react@18 or react@19 as a peer - any existing React app already satisfies it.
1. Define providers
A provider is a recipe for one piece of your application - an API client, a service, a store. You register providers in a module (we'll create one in step 3); the module constructs the instances, owns them while it lives, and disposes of them when it goes away. An instance is available to its module and to everything below it in the tree.
Providers come in several shapes - classes, factories, values, aliases (see Providers & Resolution). The simplest shape is a plain class:
import { inject } from "@remodulo/container"
class ApiClient {
async get<T>(url: string): Promise<T> {
const response = await fetch(url)
return (await response.json()) as T
}
}
class TaskService {
private readonly api = inject(ApiClient)
tasks: readonly Task[] = []
async refresh(): Promise<void> {
this.tasks = await this.api.get<Task[]>("/api/tasks")
}
}Two things worth noticing.
inject(ApiClient) in a field initializer is the entire Dependency Wiring. There are no decorators, no reflect-metadata, no constructor parameters to thread through: while a module constructs an instance, inject() reads from that module's scope. It works in field initializers and constructors - anywhere construction is running. See Injection.
And ApiClient imports nothing from the library. Any class can be a provider - there is no base class to extend and no annotation to add.
2. Add lifecycle
A provider opts into the module's lifecycle by declaring hooks - plain methods, nothing to import or extend:
import { inject } from "@remodulo/container"
class TaskService {
private readonly api = inject(ApiClient)
private timer: ReturnType<typeof setInterval> | null = null
tasks: readonly Task[] = []
onModuleMount(): void {
void this.refresh()
this.timer = setInterval(() => void this.refresh(), 30_000)
}
onModuleUnmount(): void {
if (this.timer) clearInterval(this.timer)
this.timer = null
}
async refresh(): Promise<void> {
this.tasks = await this.api.get<Task[]>("/api/tasks")
}
}onModuleMount and onModuleUnmount are ordinary methods. Nothing declares TaskService a lifecycle participant - having the method is the declaration.
Self-contained
TaskService is now self-contained: it declares what it needs (inject), what it does (refresh), and how long things live (the onModuleMount/onModuleUnmount pair). Nothing outside the class needs to know any of it.
There are four hooks, all optional:
onModuleInit(once, after the module constructs its providers),onModuleMount/onModuleUnmount(repeatable, symmetric pair tied to the module's place in the React tree - mounted when it appears, unmounted when it leaves),onModuleDestroy(once, at the end of the module's life; may be async).
class SomeProvider {
// ...
onModuleInit(): void {
// ...
}
onModuleMount(): void {
// ...
}
onModuleUnmount(): void {
// ...
}
onModuleDestroy(): void | Promise<void> {
// ...
}
}Two rules to make lifecycle safe
- Constructors and onModuleInit only allocate. Anything that touches the outside world - subscriptions, timers, sockets, fetches - starts in onModuleMount.
- Whatever mount acquires, unmount releases. The pair can run many times over the instance's life - this is exactly why StrictMode works instead of being worked around.
Modules are built during render, and React throws render attempts away routinely. An abandoned attempt leaves behind a service that will never be mounted - and because it acquired nothing, it is garbage the collector takes without your help. Move the setInterval into the constructor, and every discarded render leaks a timer.
3. Connect Reactivity
Something changed in tasks - what re-renders the list? Remodulo has no answer to that, and no opinion about it. It owns objects: when one is constructed, when it mounts, when it unmounts, when it is disposed of. What makes the view react to data is the job of whatever reactivity layer you already use:
- MobX - our direct recommendation, and what this guide uses below.
- A hand-rolled store -
subscribe+getSnapshot, read through React's ownuseSyncExternalStore. No dependencies at all. - Zustand - a vanilla store created by the service instead of at file scope.
- Or anything else - if it can subscribe a component to an object, it fits.
The shape is always the same - the module owns the service, the view subscribes to it - and Connecting Reactivity writes it out against each layer. Here we take the MobX path:
npm install mobx mobx-react-liteclass TaskService {
constructor() {
makeAutoObservable(this)
}
// everything else stays exactly as it was
}One line. tasks is now trackable, and when the view appears in step 6, wrapping it in observer is the entire subscription.
4. Connect your App
import { App, AppProvider } from "@remodulo/react"
import { ApiClient } from "./services"
const app = new App({ id: "app", providers: [ApiClient] })
export function Root(): ReactElement {
return (
<AppProvider app={app}>
{/* <Board /> - the board arrives in the next step */}
</AppProvider>
)
}App is the root module. You construct it yourself, outside the React tree - which is what lets you register host configuration into it before React ever renders, and what lets a test hold the same object. AppProvider takes it from there: initializes it, mounts it, and unmounts it with the tree.
App-level providers are the ones the whole application shares: API clients, session, theming, a router adapter. That is why ApiClient is registered here - it is stateless infrastructure any part of the app may reach for. TaskService is not: it is feature state, and it belongs to the feature's own scope. That scope is the next step.
One App per AppProvider, and an App is not revivable: once destroyed it stays destroyed, and handing a later render a different instance throws rather than silently swapping your root out.
5. Create a scoped module
import { createModuleComponent } from "@remodulo/react"
import { TaskService } from "./services"
const BoardModule = createModuleComponent({
id: "board",
providers: [TaskService],
})createModuleComponent returns a React component. Rendering it opens a scope; unmounting it closes one, and everything the module owns is buried with it.
Providers are eager by default: TaskService is constructed when the module initializes, not on first use. Declared-in-module means running - a poller with no component consumer would otherwise silently never start. Mark a provider lazy: true when you want the other behaviour (see Lazy).
Now notice what inject(ApiClient) inside TaskService does: ApiClient is not registered in this module. Resolution walks up the tree and finds it in the App - a service reaches for the nearest provider above it, and nothing is passed down by hand. And the boundary cuts both ways: close the board and TaskService is buried with it, while the ApiClient it was using outlives the boundary, untouched. That is the whole scoping model.
Why createModuleComponent
createModuleComponent is the recommended way to make a module, for three reasons:
- It returns a component. The module is defined once and dropped anywhere - rendered, reused, and moved around like any other piece of JSX.
- Props are integrated. The component's props can configure the module at birth and flow into its services as reactive values. See Props.
- It composes.
withModulepairs it with a view in one line - that is the next step.
Opening a scope by hand
import { ModuleProvider } from "@remodulo/react"
import { TaskService } from "./services"
type BoardBoundaryProps = { children?: ReactNode }
export function BoardBoundary({ children }: BoardBoundaryProps) {
return (
<ModuleProvider id="board" providers={[TaskService]}>
{children}
</ModuleProvider>
)
}ModuleProvider is the same boundary placed by hand: inline, in a tree you already render, providers passed directly. Reach for it when you need a one-off scope positioned yourself; for anything reusable, prefer the factory. Either way it needs a module above it - an AppProvider, or another module.
6. Compose module and view
import { observer } from "mobx-react-lite"
import { createModuleComponent, useResolve, withModule } from "@remodulo/react"
import { TaskService } from "./services"
const BoardModule = createModuleComponent({
id: "board",
providers: [TaskService],
})
const BoardView = observer((): ReactElement => {
const service = useResolve(TaskService)
return (
<ul>
{service.tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
)
})
export const GettingStarted = withModule(BoardModule, BoardView)That is the shape the whole library is built around: a module, a view, and withModule joining them. Board renders the view inside the scope.
useResolve asks the nearest module for an instance, walking up the module tree if it does not find one there. It is a read, not a subscription - it hands back the same instance on every render and never re-renders on its own. The subscription is the observer wrapper: the MobX path from step 3, delivering on its promise - one line there, one wrapper here. Other layers subscribe their own way; see Connecting Reactivity.
The view declares no props of its own beyond children, and the type system enforces it with a readable error. There is nowhere for another prop to come from - the composition renders <BoardView>{children}</BoardView> and nothing else. Everything the view needs, it resolves.
One thing remains: the placeholder from step 4.
export function Root(): ReactElement {
return (
<AppProvider app={app}>
<GettingStarted />
</AppProvider>
)
}Render Root and the whole story runs: the app initializes, the board opens its scope, TaskService is constructed, mount fires the first refresh and starts the interval, observer re-renders the list as tasks arrive - and when the board leaves the screen, every piece of that is released, in reverse order, by nobody in particular.
React Environment
React has opinions
React double-invokes in StrictMode, throws render attempts away, and can hide committed trees without unmounting them.
The first two are already handled: the app you just built is StrictMode-safe by construction - the two rules from step 2 are exactly what makes it so.
The third has rules of its own: <Activity> is unsupported, and <Suspense> around a module has one mandatory shape. Read React Environment before you ship - it is short, and the failure modes it prevents are silent.
Where to go next
Guides
- React Environment
- Connecting Reactivity
- Modules
- Providers & Resolution
- Lifecycle
- Props
- Container
- Dependency Injection