Skip to content

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 - createModuleComponent or <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:

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

tsx
// ✅ 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:

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

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

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

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

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

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

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

tsx
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; null for the App
  • ancestors - the chain above, nearest first, up to the App
  • children - the mounted modules directly below, in attach order
  • descendants - the whole subtree below, depth-first
  • findRoot - the App
  • findAncestorById(id) - the nearest module above with this id, or null
  • findDescendantById(id) - the first module below with this id, depth-first, or null
  • findAncestorByProvider(token) - the nearest module above that declares the provider, or null
  • findDescendantsByProvider(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.

Where to go next

Guides

Reference

MIT licensed.