Modules
A module is a scoped set of providers with a lifecycle. It mounts as a component, so modules appear and disappear with your UI - a route, a dialog, a panel - and nest into a tree, with App at the root.
This page covers what a module is, how to create one, and who owns the instances inside.
What is a Module
A module is a scope. You hand it a list of providers, and it owns them - registers them in its own container, constructs them, and runs their lifecycle.
There are two kinds of module:
- The App module - the root. Exactly one per app, constructed by you outside React and handed to
AppProvider. It holds what the whole app shares. - Scoped modules -
createModuleComponentor<ModuleProvider>. Opened by rendering, closed by unmounting: a route, a dialog, a panel.
The rest of this page is mostly about scoped modules; the App comes back in The tree.
The Tree
Modules nest the way components do. Each module's parent is the nearest module above it in the React tree, and together they form one tree - with the App module at the root.
App
The App is the only module you construct yourself:
import { App, AppProvider } from "@remodulo/react"
// Constructed outside React: providers are registered before anything renders
export const app = new App({ providers: [ApiClient] })
export function Root() {
return (
<AppProvider app={app}>
<OrdersPage />
</AppProvider>
)
}AppProvider also accepts a factory: app={() => new App({ ... })}.
Hierarchy
An App is a Module. A Module is not an App.
Only an App created with new App(...) can be handed to AppProvider.
Module
Every scoped module forks from the module above it - its parent. At the top of that chain is always the App, so a scoped module can only live under <AppProvider>:
// ✅ Has a parent to fork from
<AppProvider app={app}>
<OrdersModule />
</AppProvider>
// ❌ Throws: "ModuleProvider requires a parent module in context."
<OrdersModule />If you want the container on its own terms, without React and without modules, that is Container.
Module Creation
createModuleComponent
The recommended way to create a scoped module is createModuleComponent:
import { createModuleComponent } from "@remodulo/react"
const OrdersModule = createModuleComponent({ providers: [OrdersApi] })
function OrdersPage() {
return (
<OrdersModule>
<OrdersView />
</OrdersModule>
)
}While OrdersPage is on screen, OrdersView and everything below it can read the OrdersApi instance via useResolve(OrdersApi). When the page leaves the screen, the module closes and the instances are disposed.
More than a wrapper
createModuleComponent is a powerful factory that also handles dynamic props and props-based config - see the createModuleComponent API reference.
ModuleProvider
Alternatively, you can open a scope by hand with <ModuleProvider>:
import { ModuleProvider } from "@remodulo/react"
function OrdersPage() {
return (
<ModuleProvider providers={[OrdersApi]}>
<OrdersView />
</ModuleProvider>
)
}Good for a one-off inline scope. For anything reusable - createModuleComponent
withModule
For the common pairing - a module wrapping its view - withModule merges the two into one component:
import { withModule, useResolve } from "@remodulo/react"
const OrdersModule = createModuleComponent({ providers: [OrdersStore] })
function OrdersView({ children }: { children?: ReactNode }) {
const store = useResolve(OrdersStore)
return <section>{/*...*/}</section>
}
export const Orders = withModule(OrdersModule, OrdersView)Rendering <Orders> opens the module and renders the view inside it - one import for the consumer instead of a module-plus-view sandwich at every call site.
The view may declare no prop other than children. Every other prop belongs to the module - that story is Props.
Module Rebuild
Sometimes "update" is the wrong operation - the module needs to be a different module: same boundary, fresh scope, fresh instances.
A rebuild destroys the module and constructs a new one in its place. The old instances go through the normal teardown; the new module initializes and mounts like any other. There are two triggers.
Declarative - deps. An array compared against the previous render, element-wise with Object.is. A change rebuilds the module:
<ModuleProvider providers={[OrdersStore]} deps={[workspaceId]}>
{children}
</ModuleProvider>createModuleComponent takes the same deps in its config - and can derive it from props, which is where rebuilds usually come from: Props.
Imperative - useModuleRebuild. Any component inside the boundary can ask for a fresh scope:
function ResetButton() {
const rebuild = useModuleRebuild()
return <button onClick={rebuild}>Reset workspace</button>
}The App never rebuilds - it has no parent to be rebuilt under. Rebuild is for scoped modules.
Module Scoping
A module's scope answers two questions: who can read a provider, and who disposes it.
Direction
The scope has a direction. Everything below the module can read its providers; everything above cannot.
Modules nest the way components do. A read starts in the nearest module and walks up: a child sees its own providers first, then its parent's, all the way to the App.
const app = new App({ providers: [ApiClient] })
// ...
<AppProvider app={app}>
{/* ApiClient is declared on the App - everything below can read it */}
<ModuleProvider providers={[OrdersApi]}>
{/* OrdersView sees OrdersApi and ApiClient */}
<OrdersView />
</ModuleProvider>
{/* Anything rendered here sees ApiClient, but not OrdersApi */}
<Sidebar />
</AppProvider>Ownership
An instance belongs to the module whose providers declared it - never to the module that resolved it.
Resolution walks up the tree; ownership stays where the declaration is:
class OrdersStore {
orders: Order[] = []
}
class DetailsStore {
private readonly orders = inject(OrdersStore)
}
const OrdersModule = createModuleComponent({ providers: [OrdersStore] })
const DetailsModule = createModuleComponent({ providers: [DetailsStore] })
function OrdersPage() {
return (
<OrdersModule>
<DetailsModule>
<OrdersView />
</DetailsModule>
</OrdersModule>
)
}DetailsModule declares no OrdersStore, so the read inside DetailsStore walks up and lands on OrdersModule's declaration - DetailsStore holds OrdersModule's instance.
Unmount DetailsModule - OrdersStore keeps running: it belongs to OrdersModule, and OrdersModule is still mounted.
Unmount the whole OrdersPage - OrdersStore is unmounted and destroyed by OrdersModule, once.
In simple words
Declare a provider at the level where it should live and die; read it from anywhere below - the module that declared it buries it. That is the whole scoping model.
Module Traversal
Sometimes you need the tree itself: a devtools panel, a diagnostic, a service that has to find the module that declares a provider.
That is ModuleTraversal - a service registered in every module. inject(ModuleTraversal) works in any service, and module.traversal works from outside.
import { ModuleTraversal, type Module } from "@remodulo/react"
class Devtools {
private readonly traversal = inject(ModuleTraversal)
openModules(): Module[] {
return this.traversal.descendants()
}
// The module that declares OrdersStore - the one that owns the instance
storeOwner(): Module | null {
return this.traversal.findDescendantsByProvider(OrdersStore).at(0) ?? null
}
}ModuleTraversal answers relative to the module it was resolved in, and never includes that module itself. The full surface:
parent- the module directly above;nullfor the Appancestors- the chain above, nearest first, up to the Appchildren- the mounted modules directly below, in attach orderdescendants- the whole subtree below, depth-firstfindRoot- the AppfindAncestorById(id)- the nearest module above with this id, ornullfindDescendantById(id)- the first module below with this id, depth-first, ornullfindAncestorByProvider(token)- the nearest module above that declares the provider, ornullfindDescendantsByProvider(token)- every module below that declares the provider
Nothing is cached - every answer is derived from the modules themselves.
Modules can have an id
createModuleComponent({ id: "orders", providers: [...] }) - optional, and findAncestorById / findDescendantById look modules up by it.
Children join on mount
A child appears in children once it has mounted - that is when it joins its parent's tree. A module built during a render attempt React discarded never appears at all.