This guide covers patterns for building workspace-style applications with the modular-react framework: apps where the shell renders modules in tabs, panels, and drawers rather than via URL routes. Contact center agent desktops, trading platforms, and admin consoles are typical examples.
Prerequisite: This guide builds on Shell Patterns (Fundamentals), which covers the shared foundation: layout grids, slots, command palettes, cross-store coordination, and module-to-shell communication. Read that first.
Workspace patterns are router-agnostic. Every hook and descriptor field shown here works identically with
@react-router-modules/*and@tanstack-react-modules/*; only the imports differ. Use whichever runtime your shell already uses. Where route fallback behavior matters, the React Router and TanStack Router companion docs show the route-declaration syntax.
Use these patterns when your app has:
- Tabbed workspaces: users open and close content tabs within a persistent shell
- Component-only modules: modules render via the shell (not via URL routes)
- Per-session state: each customer/ticket/case has its own tab state, notes, etc.
- Contextual panels that change per tab: the active tab determines what shows in a sidebar
If your app is a traditional page-navigated SPA where modules own routes, the core framework + Shell Patterns are sufficient.
┌─────────────────────────────────────────────────────────────┐
│ Shell Layout (rootComponent) │
│ │
│ ┌──────┐ ┌──────────────────────────────────┐ ┌─────────┐ │
│ │ Mode │ │ Workspace │ │ Detail │ │
│ │ Rail │ │ ┌──────────────────────────────┐ │ │ Panel │ │
│ │ │ │ │ Tab Strip │ │ │ │ │
│ │ nav │ │ ├──────────────────────────────┤ │ │ zones. │ │
│ │ items│ │ │ │ │ │ detail │ │
│ │ │ │ │ Active Tab Content │ │ │ Panel │ │
│ │ │ │ │ (module component) │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ └──────────────────────────────┘ │ │ │ │
│ └──────┘ └──────────────────────────────────┘ └─────────┘ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Notes / Drawer ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
The shell owns the layout. In workspace apps, the shell controls which module is currently rendered, not the URL. The most common pattern is a tab strip where each tab renders one module's content, but the same architecture works for any shell-managed content switching: a single content area that swaps between modules, a drawer, a modal, or a split view. Modules don't know how the shell presents them; the shell decides when and where to render each module's component.
Modules contribute content through five channels:
| What | Mechanism | Example |
|---|---|---|
| Navigation items | navigation on module descriptor |
Mode rail links, sidebar items |
| Global contributions | slots on module descriptor |
Command palette entries, tab type registrations |
| Route-specific panels | Route-level zones | Detail panel for a route-based page |
| Tab-active panels | zones on module descriptor |
Contextual panel when a module tab is active |
| Runtime state | Shared Zustand stores | Active tab, session state, panel visibility |
The fourth row (descriptor-level
zones) is a sharedModuleDescriptorfield and works identically across both routers. Row three (route-specific panels) is the one that varies: React Router useshandle, TanStack Router usesstaticData.
// app-shared/src/index.ts
import { createSharedHooks } from "@react-router-modules/core";
// ^ or "@tanstack-react-modules/core" (createSharedHooks is identical in both)
import type { ComponentType } from "react";
// ---- Zones (layout regions that change per active content) ----
export interface AppZones {
contextualPanel?: ComponentType;
headerActions?: ComponentType;
}
// For TanStack Router only: augment staticData so it type-checks at createRoute.
// React Router does not need augmentation; handle is unknown and narrowed by useZones<AppZones>().
declare module "@tanstack/router-core" {
interface StaticDataRouteOption extends AppZones {}
}
// ---- Slots (global contributions from all modules) ----
export interface CommandDefinition {
readonly id: string;
readonly label: string;
readonly group?: string;
readonly onSelect: () => void;
}
export interface AppSlots {
commands: CommandDefinition[];
systems: SystemRegistration[];
subNavSections: SubNavSection[];
}
// ---- Services ----
export interface WorkspaceActions {
openModuleTab: (moduleId: string) => void;
openSectionTab: (sectionId: string) => void;
}
// ---- Shared dependencies ----
export interface AppDependencies {
auth: AuthStore;
sessions: SessionsStore;
ui: UIStore;
httpClient: { get: (url: string) => Promise<unknown> };
workspace: WorkspaceActions;
}
// ---- Module metadata for catalog discovery ----
// Define your own metadata shape. The framework passes it through via TMeta generic.
export interface WorkflowMeta {
readonly name: string;
readonly description: string;
readonly icon: string;
readonly category: string;
readonly estimatedTime?: string;
readonly keepOpenOnComplete?: boolean;
readonly addNoteOnComplete?: boolean;
}
// ---- Typed hooks ----
export const { useStore, useService, useReactiveService, useOptional } =
createSharedHooks<AppDependencies>();The declare module "@tanstack/router-core" augmentation is only needed if you're using the TanStack Router integration. Skip it for React Router apps.
The tab/workspace system is shell-owned state, not a framework concern. Use a plain Zustand store:
// shell/src/stores/workspace.ts
import { createStore } from "zustand/vanilla";
export interface WorkspaceTab {
id: string;
type: "directory" | "iframe" | "native-workflow";
title: string;
workflowId?: string; // for native-workflow tabs
iframeUrl?: string; // for iframe tabs
closeable: boolean;
lastAccessedAt: number;
}
export interface TabStateBySession {
tabs: WorkspaceTab[];
activeTabId: string;
}
export const workspaceTabsStore = createStore<WorkspaceTabsState>((set, get) => ({
tabStateBySession: {},
getCurrentTabs: (sessionId) => {
return get().tabStateBySession[sessionId]?.tabs ?? [createDirectoryTab()];
},
getActiveTab: (sessionId) => {
const tabState = get().tabStateBySession[sessionId];
if (!tabState) return null;
return tabState.tabs.find((t) => t.id === tabState.activeTabId) ?? null;
},
openTabForSession: (sessionId, tab) =>
set((state) => {
// Activate existing tab or append new one, with LRU eviction
// ...
}),
// closeTab, switchTab, etc.
}));When a new session is selected, initialize its tab state. Use Zustand's subscribe API (see Cross-Store Coordination):
sessionsStore.subscribe((state, prev) => {
if (state.activeSessionId === prev.activeSessionId) return;
const id = state.activeSessionId;
if (!id) return;
const tabs = workspaceTabsStore.getState();
if (!tabs.tabStateBySession[id]) {
workspaceTabsStore.setState({
tabStateBySession: {
...tabs.tabStateBySession,
[id]: { tabs: [createDirectoryTab()], activeTabId: "directory" },
},
});
}
});Workspace modules use component instead of createRoutes. The shell renders them in tabs. They declare meta for catalog discovery and zones for contextual panels:
// modules/onboarding-flow/src/index.ts
import { defineModule } from "@react-router-modules/core"; // or "@tanstack-react-modules/core"
import { lazy } from "react";
import { OnboardingPanel } from "./OnboardingPanel.js";
export default defineModule<AppDependencies, AppSlots, WorkflowMeta>({
id: "onboarding-flow",
version: "0.1.0",
// The shell renders this in a workspace tab
component: lazy(() => import("./OnboardingFlow.js")),
// Catalog metadata: shell reads via useModules() + getModuleMeta()
meta: {
name: "Customer Onboarding",
description: "Walk through the new customer setup process",
icon: "UserPlus",
category: "setup",
estimatedTime: "5-10 mins",
},
// Zones: shell reads via useActiveZones() when this module's tab is active
zones: {
contextualPanel: OnboardingPanel,
},
requires: ["auth", "httpClient"],
});Both component and zones live on the shared ModuleDescriptor, so this module definition is valid against either integration's defineModule.
The shell defines a standard props interface for workspace components:
export interface WorkflowProps {
customerId: string;
accountNumber: string;
onComplete: (result?: unknown) => void;
onCancel: () => void;
initialState?: unknown;
}Tab-based modules can't declare zones on a route because they aren't rendered via routes. Instead, they declare zones on the module descriptor:
zones: {
contextualPanel: OnboardingPanel,
headerActions: OnboardingHeaderActions,
}The shell reads zones from both routes and the active module using useActiveZones:
import { useActiveZones } from '@react-router-modules/runtime'
// ^ or '@tanstack-react-modules/runtime' (identical API)
import type { AppZones } from '@myorg/app-shared'
function ShellLayout() {
// Derive the active module ID from workspace tab state
const activeTab = getActiveTabForCurrentSession()
const activeModuleId =
activeTab?.type === 'native-workflow' ? activeTab.workflowId : null
const zones = useActiveZones<AppZones>(activeModuleId)
const ContextualPanel = zones.contextualPanel
return (
<div className="grid ...">
{/* ... other zones ... */}
<aside>
{ContextualPanel ? <ContextualPanel /> : <DefaultPanel />}
</aside>
</div>
)
}How useActiveZones works:
- Collects route zones via
useZones()(fromhandleon React Router matched routes, orstaticDataon TanStack matched routes). - If
activeModuleIdis provided, looks up the module'szonesfield fromuseModules(). - Merges both: module wins for the same key.
- When
activeModuleIdisnull, returns route zones only.
This gives the shell one code path regardless of whether the active content is route-based or tab-based.
When the user clicks a different tab, the zone layout updates through a reactive chain, no imperative wiring needed:
User clicks tab "Billing"
→ workspaceTabsStore updates activeTabId
→ ShellLayout re-renders (subscribed to the store)
→ derives activeModuleId = "billing" from the new active tab
→ useActiveZones("billing") returns billing module's zones
→ layout renders BillingContextPanel in the aside
Each step is a standard React/Zustand subscription. The shell layout subscribes to the tab store, derives activeModuleId from the active tab, and passes it to useActiveZones. When the tab changes, React re-renders the layout and useActiveZones returns the new module's zones automatically.
When switching to a tab that has no module (e.g. a directory tab or an iframe tab), activeModuleId resolves to null, and useActiveZones(null) falls back to route zones only. If no route contributes zones either, every zone key is undefined and the shell renders its fallback content.
The shell builds a browsable directory of available modules using useModules() and getModuleMeta():
import { useModules, getModuleMeta } from '@react-router-modules/runtime'
import type { WorkflowMeta } from '@myorg/app-shared'
function DirectoryPage() {
const modules = useModules()
// Only show modules that have catalog metadata
const discoverable = modules.filter((m) => getModuleMeta<WorkflowMeta>(m)?.category)
// Group by category
const byCategory = Map.groupBy(discoverable, (m) =>
getModuleMeta<WorkflowMeta>(m)!.category
)
return (
<div>
{[...byCategory.entries()].map(([category, mods]) => (
<section key={category}>
<h2>{capitalize(category)}</h2>
<div className="grid grid-cols-3 gap-4">
{mods.map((mod) => {
const meta = getModuleMeta<WorkflowMeta>(mod)!
return (
<Card key={mod.id}>
<h3>{meta.name}</h3>
<p>{meta.description}</p>
<button onClick={() => openModuleTab(mod.id)}>
{meta.estimatedTime ? 'Start' : 'Open'}
</button>
</Card>
)
})}
</div>
</section>
))}
</div>
)
}Category labels fall back to capitalize(category); no hardcoded label map needed.
When a tab is active, the shell looks up the module and renders its component:
import { useModules } from '@react-router-modules/runtime'
function WorkspaceContent({ activeTab, customerId, accountNumber, sessionId }) {
const modules = useModules()
if (activeTab.type === 'directory') return <DirectoryPage />
if (activeTab.type === 'iframe') {
return <IframeContainer url={activeTab.iframeUrl} title={activeTab.title} />
}
// native-workflow: look up the module
const mod = modules.find((m) => m.id === activeTab.workflowId)
if (!mod?.component) return <p>Module "{activeTab.workflowId}" not found</p>
return (
<WorkflowWrapper
workflowId={activeTab.workflowId}
customerId={customerId}
accountNumber={accountNumber}
sessionId={sessionId}
tabId={activeTab.id}
/>
)
}The wrapper handles completion behavior and error boundaries:
function WorkflowWrapper({ workflowId, customerId, accountNumber, sessionId, tabId }) {
const modules = useModules()
const mod = modules.find((m) => m.id === workflowId)
const meta = getModuleMeta<WorkflowMeta>(mod!)
const handleComplete = (result?: unknown) => {
saveWorkflowState(workflowId, result)
// Respect module's completion preferences
if (meta?.addNoteOnComplete !== false) {
addNote(sessionId, `Workflow completed: ${meta?.name ?? workflowId}`)
}
if (!meta?.keepOpenOnComplete) {
closeTab(sessionId, tabId)
}
}
const Component = mod!.component!
return (
<ErrorBoundary>
<Suspense fallback={<LoadingSpinner />}>
<Component
customerId={customerId}
accountNumber={accountNumber}
onComplete={handleComplete}
onCancel={() => closeTab(sessionId, tabId)}
initialState={loadWorkflowState(workflowId)}
/>
</Suspense>
</ErrorBoundary>
)
}Modules control their own completion behavior via WorkflowMeta:
keepOpenOnComplete: true: tab stays open; module shows its own post-completion UIaddNoteOnComplete: false: no automatic note on completion
For apps where each session has independent state, use createScopedStore:
import { createScopedStore } from "@react-router-modules/core";
// ^ or "@tanstack-react-modules/core" (identical API)
const sessionTabs = createScopedStore<TabState>(() => ({
tabs: [{ id: "directory", type: "directory", title: "Directory", closeable: false }],
activeTabId: "directory",
}));
// In a component: subscribe to this session's tab state
function Workspace({ sessionId }: { sessionId: string }) {
const { tabs, activeTabId } = sessionTabs.useScoped(sessionId);
// ...
}
// Cleanup when session ends
sessionTabs.remove(sessionId);Modules should never import store instances directly. Expose a workspace actions service via AppDependencies:
// app-shared/src/index.ts
export interface WorkspaceActions {
/** @deprecated Use openTab({ kind: 'module', id, input }) instead. */
openModuleTab: (moduleId: string) => void;
openSectionTab: (sectionId: string) => void;
/**
* Unified tab opener. `kind: 'module'` swaps in a module; `kind: 'journey'`
* starts (or resumes) a journey instance for a multi-module workflow.
* See [Journeys](../packages/journeys/README.md) for the typed entry/exit contracts and
* persistence pipeline.
*/
openTab: (
spec:
| { kind: "module"; id: string; entry?: string; input?: unknown; title?: string }
| { kind: "journey"; id: string; input?: unknown; title?: string },
) => { tabId: string; instanceId?: string };
}Single-module tabs are { kind: 'module' }. When a domain workflow spans several modules with shared state, prefer { kind: 'journey' } — the shell mounts a <JourneyOutlet> inside the tab and the journey owns transitions and serializable state. See Journeys for the full contract.
The shell provides the implementation. Modules only know the interface:
import { useService } from '@myorg/app-shared'
function InvoiceActions({ invoiceId }: { invoiceId: string }) {
const workspace = useService('workspace')
return (
<button onClick={() => workspace.openModuleTab('payments')}>
Pay Invoice
</button>
)
}Single-module tabs ({ kind: 'module' }) cover the common case: one module, one tab, self-contained. When a tab's work spans several modules with shared state — "confirm the customer's profile, then branch into plan selection, then either collect a payment or activate a free trial" — the mechanics above start to bend: teams fan state through the shell's stores, modules grow implicit dependencies on each other's keys, and mid-flow recovery after a reload has to be built by hand.
Journeys are the dedicated abstraction for this case. Modules declare typed entryPoints and exitPoints; a journey declares how one module's exit feeds the next module's entry and owns the shared state for the whole flow; the shell mounts a <JourneyOutlet> inside the tab.
Mount one <JourneyProvider> near the top of the shell so outlets and module tabs read the runtime (and the global onModuleExit) from context — no prop threading:
import { JourneyProvider, JourneyOutlet, ModuleTab } from "@modular-react/journeys";
function Shell({ manifest }: { manifest: ResolvedManifest }) {
return (
<JourneyProvider runtime={manifest.journeys} onModuleExit={manifest.onModuleExit}>
{/* tabs, routes, … */}
</JourneyProvider>
);
}
function TabContent({ tab, manifest }: { tab: Tab; manifest: ResolvedManifest }) {
if (tab.kind === "module") {
return (
<ModuleTab
module={manifest.moduleDescriptors[tab.moduleId]}
entry={tab.entry}
input={tab.input}
tabId={tab.tabId}
// Provider-level onModuleExit fires automatically — only wire a
// per-tab onExit when you need extra shell-specific behavior.
onExit={(ev) => workspace.closeTab(tab.tabId)}
/>
);
}
return (
<JourneyOutlet
instanceId={tab.instanceId}
loadingFallback={<LoadingSpinner />}
onFinished={() => workspace.closeTab(tab.tabId)}
/>
);
}manifest.journeys is always a runtime — even when no journey is registered it is a no-op runtime (listDefinitions() === [], start() throws "unknown journey id"), so shells don't null-guard it.
What each side owns:
- Modules stay journey-unaware. They declare typed entries (input → component) and typed exits (outcome names + output shapes). The host — whether that host is
<JourneyOutlet>or the standalone<ModuleTab>— supplies a typedexit(name, output)callback. A module's component code never branches on "am I in a journey?". - The journey definition declares the module map (
import typeonly — no runtime coupling), the transition graph, and its own private state. Transitions are pure synchronous functions. - The shell registers journeys on the registry (
registry.registerJourney(def, { persistence })) and mounts<JourneyOutlet>inside its existing tab, modal, or route container. It doesn't learn anything about specific journeys' logic.
When to reach for it:
| Your workflow… | Use |
|---|---|
| is one module with no explicit outcome routing | { kind: 'module' } via <ModuleTab> |
| emits named outcomes but the caller decides what happens next | <ModuleTab> + onExit in the shell |
| spans multiple modules and needs shared state that survives reload | { kind: 'journey' } via <JourneyOutlet> |
Journey state is serializable — pluggable keyFor / load / save / remove adapter lets you wire localStorage, a backend, or any session store. Two runtime.start(id, input) calls for the same key return the same instanceId, so mid-flow reload recovery is a few lines in main.tsx:
const { journeys } = registry.resolve(...);
for (const tab of tabsStore.getState().tabs) {
if (tab.kind !== 'journey') continue;
const resolvedId = journeys.start(tab.journeyId, tab.input);
if (resolvedId !== tab.instanceId) tabsStore.getState().replaceInstanceId(tab.tabId, resolvedId);
}See the journeys package README for the full contract, a worked customer-onboarding example, testing utilities, and the error / edge-case guarantees. The examples/react-router/customer-onboarding-journey/ example project demonstrates the pattern end-to-end with a localStorage persistence adapter and reload-recovery; examples/react-router/integration-setup-journey/ (and its TanStack mirror) covers the state-driven dispatch variant — a chooser module fed by slots picks a value, and the journey routes the next step via selectModule / selectModuleOrDefault for exhaustive or fallback dispatch.
Zones are reactive; they re-derive on every route change and tab switch. There is no implicit default and no "sticky" carry-over from a previous page or tab.
Initial render: When no route or module contributes a zone, every key is undefined. The shell layout should render fallback content:
{zones.contextualPanel ? <zones.contextualPanel /> : <DefaultPanel />}Tab switch: When the user switches from a tab whose module declares zones: { contextualPanel: BillingPanel } to a tab whose module declares no zones, contextualPanel reverts to whatever the route hierarchy provides, or undefined if no route sets it either. This is intentional: the shell always reflects the currently active content, not the previously active content.
Persistent zones across tabs: If a zone should always be present regardless of the active tab, set it on a parent layout route (via handle on React Router, staticData on TanStack Router). Module descriptor zones override route zones for the same key, so the route value acts as a fallback when the active module doesn't contribute that zone.
| Concern | Owned by | Mechanism |
|---|---|---|
| Layout grid, zone placement | Shell | rootComponent with CSS Grid |
| Module identity and catalog metadata | Modules | meta on descriptor → useModules() |
| Module renderable component | Modules | component on descriptor → useModules() |
| Tab-active contextual panels | Modules | zones on descriptor → useActiveZones() |
| Route-specific panels/actions | Modules | handle (RR) or staticData (TSR) → useActiveZones() |
| Navigation items | Modules | navigation on descriptor |
| Command palette entries | Modules | slots.commands |
| Directory page | Shell | Reads useModules(), filters by meta |
| Tab state, active tab | Shell | Zustand store |
| Per-session state | Shell | createScopedStore |
| Tab rendering | Shell | Looks up module by id via useModules(), renders component |
The framework provides the composition primitives. The shell owns the workspace architecture. Modules stay standalone and testable: they declare what they contribute, the shell decides where it goes.