npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@modular-react/journeys

v1.10.0

Published

Typed, serializable workflows that compose multiple modules. A journey declares entry/exit transitions between modules and owns shared state; modules stay journey-unaware.

Downloads

5,332

Readme

@modular-react/journeys

Typed, serializable workflows that compose several modules. A journey declares how one module's exit feeds the next module's entry; the modules themselves stay journey-unaware - they just declare what input they accept and what outcomes they can emit.

Use this package when a domain flow spans multiple modules with shared state (e.g. "confirm the customer's profile → branch into plan selection → collect a payment or activate a free trial"), and you want:

  • typed end-to-end module boundaries,
  • serializable state so a mid-flow reload or hand-off survives,
  • a single place that owns transitions, instead of cross-cutting glue inside module stores.

Routes, slots, navigation, workspaces - none of that changes. Journeys sit on top of the existing framework. Apps that don't register a journey incur nothing beyond the package being statically linked.

Prerequisite reading

Contents

Installation

The journey runtime is already a transitive dependency of @react-router-modules/runtime and @tanstack-react-modules/runtime. Install it directly only when the shell needs to type against journey types (usually it does):

pnpm add @modular-react/journeys

Peer deps: @modular-react/core, @modular-react/react, react, react-dom.

If you scaffolded your project with the modular-react CLI, you can scaffold a journey package the same way - see § Quickstart shortcut: scaffold the journey package below.

Using this with Vue

There's a full Vue 3 binding: @modular-vue/journeys (peer deps @modular-frontend/core, @modular-vue/vue, vue). The journey enginedefineJourney, transitions, branching, persistence, rewind, the runtime, handles, selectModule*, and every type — is framework-neutral (it lives in @modular-frontend/journeys-engine) and is re-exported identically by both bindings, so journey definitions and module entry/exit contracts are written exactly the same way whichever framework you ship.

What changes is the UI layer:

  • Import the outlet, provider, plugin, and composables from @modular-vue/journeys instead of @modular-react/journeys. <JourneyOutlet>, <ModuleTab>, <JourneyProvider>, journeysPlugin(), useJourneyState, useWaitForExit all have the same names and roles.
  • Journey steps are module-entry SFCs: defineProps<ModuleEntryProps<Input, Exits>>() and call the typed exit(name, payload) — the analog of the React entry component's props.
  • The subscription composables return Vue refs rather than plain values (e.g. useJourneyStateComputedRef<TState | null>), matching the rest of the Vue binding; read .value in <script>, auto-unwrapped in <template>.

See examples/vue/customer-onboarding-journey for a runnable branching, persisted journey, and Getting started with Vue Router for the surrounding shell setup. The React code below uses React imports and JSX; translate the imports and the entry components to SFCs for Vue — the journey and module contracts are unchanged.

Mental model

Three roles, strictly separated:

| Role | Owns | Does NOT know about | | ----------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | | Module | Its entry components, input types, exit names, exit output types. | Journeys. Who opens it. What comes next. | | Journey | The modules it composes (by type), transitions between entry/exit pairs, shared state. | Shell. Tabs. Routes. | | Shell | Registering modules + journeys, mounting <JourneyOutlet> inside its container (tab, route, modal, panel). | Any specific journey's logic, state, or transitions. |

Quickstart shortcut: scaffold the journey package

If you used the modular-react CLI to bootstrap your project, you can skip writing the journey package boilerplate by hand. Run:

# React Router
npx @react-router-modules/cli create journey customer-onboarding \
  --modules profile,plan,billing --persistence

# TanStack Router
npx @tanstack-react-modules/cli create journey customer-onboarding \
  --modules profile,plan,billing --persistence

That generates journeys/customer-onboarding/ with a typed defineJourney definition, a defineJourneyHandle token, type-only imports for each named module, and (with --persistence) a createWebStoragePersistence adapter at shell/src/customer-onboarding-persistence.ts. It also installs journeysPlugin() on the shell's registry and adds registry.registerJourney(...). The start step and per-module transitions map are left as // TODO comments - fill those in by working through the steps below.

If you're not using the CLI (or you want to understand the moving parts before reaching for it), the manual walkthrough follows.

Quickstart

1. Declare a module's entry and exit vocabulary

Modules import only from @modular-react/core:

// modules/profile/src/exits.ts
// Each key here is an *exit name* the profile module can emit. The generic on
// `defineExit<T>()` declares the `output` payload shape that exit ships. The
// journey's transition map (see step 2) keys handlers off these exact names.
import { defineExit } from "@modular-react/core";
import type { PlanHint } from "./types.js";

export const profileExits = {
  profileComplete: defineExit<{ customerId: string; hint: PlanHint }>(),
  readyToBuy: defineExit<{ customerId: string; amount: number }>(),
  needsMoreDetails: defineExit<{ customerId: string; missing: string }>(),
  cancelled: defineExit(), // no output payload
} as const;
export type ProfileExits = typeof profileExits;
// modules/profile/src/ReviewProfile.tsx
import type { ModuleEntryProps } from "@modular-react/core";
import type { ProfileExits } from "./exits.js";

export function ReviewProfile({
  input,
  exit,
}: ModuleEntryProps<{ customerId: string }, ProfileExits>) {
  const customer = useCustomer(input.customerId);
  const hint = suggestPlan(customer);

  if (customer.readiness === "needs-details") {
    return (
      <button
        onClick={() =>
          exit("needsMoreDetails", {
            customerId: input.customerId,
            missing: customer.readinessDetail,
          })
        }
      >
        Flag for back-office
      </button>
    );
  }
  return (
    <>
      <ProfileSummary customer={customer} hint={hint} />
      <button onClick={() => exit("profileComplete", { customerId: input.customerId, hint })}>
        Pick a plan
      </button>
      {customer.readiness === "self-serve" && (
        <button
          onClick={() =>
            exit("readyToBuy", {
              customerId: input.customerId,
              amount: selfServeAmount(customer),
            })
          }
        >
          Skip ahead - charge now
        </button>
      )}
      <button onClick={() => exit("cancelled")}>Cancel</button>
    </>
  );
}
// modules/profile/src/index.ts
import { defineModule, defineEntry, schema } from "@modular-react/core";
import { profileExits } from "./exits.js";
import { ReviewProfile } from "./ReviewProfile.js";

export default defineModule({
  id: "profile", // module id - referenced by journeys as `module: "profile"`
  version: "1.0.0",
  exitPoints: profileExits, // the full exit vocabulary shared by every entry on this module
  entryPoints: {
    // Each key here is an *entry name* - a typed way to open this module.
    // Journeys reference it as `entry: "review"`.
    review: defineEntry({
      component: ReviewProfile,
      input: schema<{ customerId: string }>(), // `input` shape passed when the entry is opened
    }),
  },
});

The exits const pattern (define once, share between component typing and module descriptor) is the canonical shape. schema<T>() is a type-only brand - zero runtime work.

2. Declare the journey

// journeys/customer-onboarding/src/journey.ts
import { defineJourney } from "@modular-react/journeys";
import type profileModule from "@myorg/module-profile";
import type planModule from "@myorg/module-plan";
import type billingModule from "@myorg/module-billing";

type Modules = {
  readonly profile: typeof profileModule;
  readonly plan: typeof planModule;
  readonly billing: typeof billingModule;
};

interface OnboardingState {
  customerId: string;
  hint: PlanHint | null;
  selectedPlan: SubscriptionPlan | null;
}

export const customerOnboardingJourney = defineJourney<Modules, OnboardingState>()({
  id: "customer-onboarding",
  version: "1.0.0",
  initialState: ({ customerId }: { customerId: string }) => ({
    customerId,
    hint: null,
    selectedPlan: null,
  }),
  start: (s) => ({ module: "profile", entry: "review", input: { customerId: s.customerId } }),
  // The `transitions` map is nested three levels deep:
  //   1. module id   - which composed module (matches a key in `Modules` above)
  //   2. entry name  - which entry on that module the handler covers
  //   3. exit name   - which exit fired by that entry triggers the handler
  // Each leaf is a pure function returning the next step, a state rewrite,
  // a `complete`, or an `abort`.
  transitions: {
    profile: {
      // module id - matches the `profile` key in `Modules` above
      review: {
        // entry name on the `profile` module - see `entryPoints.review` in modules/profile/src/index.ts
        // Exit names below are the keys of `profileExits` declared in modules/profile/src/exits.ts.
        profileComplete: ({ output, state }) => ({
          state: { ...state, hint: output.hint },
          next: {
            module: "plan",
            entry: "choose",
            input: { customerId: state.customerId, hint: output.hint },
          },
        }),
        readyToBuy: ({ output }) => ({
          next: {
            module: "billing",
            entry: "collect",
            input: { customerId: output.customerId, amount: output.amount },
          },
        }),
        needsMoreDetails: ({ output }) => ({
          abort: { reason: "profile-incomplete", missing: output.missing },
        }),
        cancelled: () => ({ abort: { reason: "rep-cancelled" } }),
      },
    },
    // …transitions for `plan` and `billing` follow the same module -> entry -> exit shape.
  },
});

Module imports are import type - the journey never pulls a module into its bundle. Runtime resolution happens by id against the registry.

3. Register the journey in the shell

Attach the journeys plugin to enable registry.registerJourney. Without .use(journeysPlugin()) the method isn't on the base registry:

import { createRegistry } from "@react-router-modules/runtime"; // or @tanstack-react-modules/runtime
import { journeysPlugin } from "@modular-react/journeys";
import { customerOnboardingJourney } from "@myorg/journey-customer-onboarding";

const registry = createRegistry<AppDeps, AppSlots>({ stores, services }).use(
  // Call once per registry - the plugin closes over its own registration
  // list. The optional `onModuleExit` is the shell-wide dispatcher for
  // module exits fired outside a journey step (see "`JourneyProvider` +
  // context" below).
  journeysPlugin({
    onModuleExit: (ev) => workspace.closeTab(ev.tabId),
  }),
);

registry.register(profileModule);
registry.register(planModule);
registry.register(billingModule);

// All registration options shown below are optional - a bare
// `registry.registerJourney(customerOnboardingJourney)` is valid and
// gives you an in-memory journey with no reload recovery.
registry.registerJourney(customerOnboardingJourney, {
  persistence: defineJourneyPersistence<OnboardingInput, OnboardingState>({
    keyFor: ({ input }) => `journey:${input.customerId}:customer-onboarding`,
    load: (k) => backend.loadJourney(k),
    save: (k, b) => backend.saveJourney(k, b),
    remove: (k) => backend.deleteJourney(k),
  }),
  // Cap `history` growth for long-running journeys. See the caveat in
  // [Bounded history (`maxHistory`)](#pattern--bounded-history-maxhistory).
  // maxHistory: 50,
});

export const manifest = registry.resolveManifest();

registry.registerJourney validates the definition's structural shape right away (missing id / version / transitions etc. throw a JourneyValidationError). The deeper contract check - that every module id, entry name, exit name, and allowBack pairing actually matches the registered modules - runs at resolveManifest() / resolve() time.

defineJourneyPersistence<TInput, TState> is the recommended shape for the adapter: it ties keyFor's input to the journey's TInput so no as { customerId: string } cast is needed, and typechecks load / save against the journey's state end-to-end. Plain objects matching JourneyPersistence still work if you prefer.

4. Render the journey in a tab (or any container)

The plugin mounts <JourneyProvider> automatically - descendant <JourneyOutlet> / <ModuleTab> nodes read the runtime (and the plugin-level onModuleExit) from context with no extra wiring. Just render the outlet wherever the step should live:

import { JourneyOutlet, ModuleTab } from "@modular-react/journeys";

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}
        // The plugin's `onModuleExit` fires automatically for every module
        // tab; pass `onExit` only for a per-tab override (typically "close
        // this tab").
        onExit={(ev) => workspace.closeTab(tab.tabId)}
      />
    );
  }
  return (
    <JourneyOutlet
      instanceId={tab.instanceId}
      loadingFallback={<LoadingSpinner />}
      onFinished={(outcome) => workspace.closeTab(tab.tabId)}
    />
  );
}

If the shell needs to reach a different runtime from the same tree (multi-tenant dashboards, split-screen agents), mount an explicit <JourneyProvider runtime={otherRuntime}> locally - the explicit prop wins over the plugin's provider. The manual-mount path is also still how you'd wire journeys in a shell that doesn't use @react-router-modules/runtime / @tanstack-react-modules/runtime at all.

manifest.journeys is always a runtime - even when no journey is registered it's a no-op runtime whose listDefinitions() / listInstances() return empty and whose start() throws the usual "unknown journey id" error. Shells don't need to null-guard it.

5. Open the journey

Export a handle alongside the journey definition so callers can open it with a typed input without importing the journey's runtime code:

// journeys/customer-onboarding/src/index.ts
import { defineJourneyHandle } from "@modular-react/journeys";
export const customerOnboardingHandle = defineJourneyHandle(customerOnboardingJourney);

The shell (or any module) then passes the handle to runtime.start. Typically this lives inside an openTab-style service so the workspace bookkeeping and the journey start are one call-site:

// In the shell, with `manifest.journeys` in scope:
const instanceId = manifest.journeys.start(customerOnboardingHandle, { customerId });
workspace.addJourneyTab({
  instanceId,
  journeyId: customerOnboardingHandle.id,
  input: { customerId },
  title: `Onboarding - ${customerName}`,
});

See the customer-onboarding-journey example for a complete working shell, including the dispatcher that also handles the string-id form used by plugin-contributed navbar actions.

Core concepts

Entry points and exit points on a module

Two additive (optional) fields on ModuleDescriptor:

| Field | Shape | Purpose | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | entryPoints | { [name]: { component, input?, allowBack?, buildInput? } }or { [name]: { lazy: () => import("./X"), fallback?, input?, allowBack?, buildInput? } } | Typed ways to open the module. A module can expose several. Each entry is either eager (a directly-bound component) or lazy (a dynamic-import factory — see Pattern - lazy entry-points). buildInput?: (state) => TInput derives the step's input from the host journey's state at every entry — see Pattern - buildInput for re-entered forms. | | exitPoints | { [name]: { output? } } | The module's full outcome vocabulary. |

ModuleEntryProps<TInput, TExits> typed props for the component - { input, exit, goBack?, goForward? }, with exit(name, output) cross-checked against TExits at compile time. goForward is the inverse of goBack — present only when the future / redo stack has an entry to restore (i.e. the user just called goBack and no fresh exit has cleared the redo target). Most shells wire Forward at the shell level (browser button); the per-component prop is for steps that surface an in-page redo control.

Exits are module-level, not per-entry - every entry on a module shares the same exitPoints vocabulary. The journey's transition map (not the module) decides which exits a given entry actually uses, so two entries on the same module can map the same exit name to entirely different next steps.

mountKinds — opting an entry out of journeys

Some entries belong to a single host surface. A panel designed for a composition zone reads composition state via useCompositionDispatch / useCompositionEmit and has no use for exit/goBack/goForward — mounting it as a journey step would silently strand the user. Conversely, a journey-shaped step has nowhere to dispatch composition events from a composition zone.

defineEntry({ mountKinds: [...] }) lets an entry declare which hosts it accepts:

defineEntry({
  component: CheckoutStep,
  input: schema<{ amount: number }>(),
  mountKinds: ["journey"], // journey only — composition selectors reject this entry
});

defineEntry({
  component: EditorPanel,
  input: schema<{ documentId: string }>(),
  mountKinds: ["composition"], // composition only — journey transitions reject this entry
});

defineEntry({
  component: SharedHeader,
  input: schema<void>(),
  mountKinds: ["journey", "composition"], // both — the default if omitted
});

Enforcement runs in two places:

  1. Compile timeStepSpec<TModules> filters out entries that don't include "journey". A transition handler returning { module, entry, input } for a composition-only entry is a type error at the transition site; the diagnostic enumerates the entries that ARE journey-mountable on that module.
  2. Render time — if a step ever resolves to a composition-only entry through a type-bypass path (a dynamic id, an as never cast), the journey outlet renders a clear error fallback instead of mounting the wrong-surface panel.

Backward compatibility: omitting mountKinds is treated as "every surface" — pre-v1.5 modules continue to work in journeys and compositions without changes.

The annotation captures intent, not capability: a module can declare mountKinds: ["journey", "composition"] and still ship a component that crashes outside a journey. The compile-time filter trusts the declaration; a panel that calls exit(...) inside a composition zone gets a separate dev-warn (see the compositions package).

allowBack - three values

Declared per entry on the module, opted-in per transition on the journey. Both must agree for goBack to appear.

| Value | What happens on goBack | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | 'preserve-state' | History pops; journey state is untouched. | | 'rollback' | History pops AND journey state reverts to the snapshot taken before this step was entered (shallow clone - treat state as immutable). | | false / absent | goBack is undefined in the component's props. Don't render the back button. |

The journey's transition map matches with allowBack: true on the exit block:

transitions: {
  plan: {                 // module id
    choose: {             // entry name on the `plan` module
      allowBack: true,    // journey-side opt-in (paired with the entry's `allowBack` declaration)
      choseStandard: …,   // exit name -> handler (omitted)
    },
  },
}

A resolveManifest() error surfaces if the two sides disagree.

goForward — redoing a goBack

Each goBack pushes the step it rewinds from (plus state + rollback snapshot) onto a per-instance future stack. runtime.goForward(id) (or ModuleEntryProps.goForward?.()) pops the top of that stack and restores the runtime to the rewound step. The captured post-transition state wins — for a rollback-mode entry, edits the user made between the rewind and the redo are discarded.

Key points:

  • Not gated by allowBack. The future entry was created by a goBack that already passed both opt-ins; the inverse is always valid.
  • Not the same as re-firing the exit. Transition handlers don't re-run on goForward, so API calls / telemetry / persistence in the handler body fire once, not twice. If business logic needs to re-execute, the shell fires the exit itself.
  • Not symmetric with goBack on buildInput. goBack re-runs buildInput against the rolled-back state. goForward trusts the step's captured input verbatim (the input was already built against the same state being restored; re-running a non-pure buildInput would diverge from what the original transition produced).
  • Cleared by any fresh exit. A { next | complete | abort } arm wipes the future stack (matches browsers — a new navigation drops Forward). invoke leaves it intact (parent step doesn't advance while a child runs).
  • Transient. Not persisted: reload always starts with an empty future stack, same as opening a new browser tab.
  • Inspectable. JourneyInstance.future: readonly JourneyStep[] exposes the stack so shells can gate a Forward button on instance.future.length > 0 without reaching into runtime internals. Top of stack is the next step a redo would land on.

Breadcrumb / edit-and-revisit wizards

A long wizard typically wants a breadcrumb header so the user can click step 4 while on step 8, edit a field, then walk forward through every intermediate step (5 → 6 → 7 → 8) without losing data already entered downstream. The journey runtime ships the primitive for this: runtime.rewindTo(id, historyIndex), a transactional multi-step goBack.

// Render breadcrumbs from JourneyInstance.history plus the current
// step (which isn't in `history`). Gate each historical chip on
// canRewindTo so opt-outs disable the affordance instead of silently
// no-opping; the current chip is the "you are here" marker and isn't
// clickable.
function Breadcrumbs({ instanceId, runtime }: { instanceId: InstanceId; runtime: JourneyRuntime }) {
  const instance = useJourneyInstance(instanceId);
  return (
    <nav>
      {instance.history.map((frame, i) => (
        <button
          key={i}
          disabled={!runtime.canRewindTo(instanceId, i)}
          onClick={() => runtime.rewindTo(instanceId, i)}
        >
          {frame.moduleId}.{frame.entry}
        </button>
      ))}
      {instance.step && (
        <span aria-current="step">
          {instance.step.moduleId}.{instance.step.entry}
        </span>
      )}
    </nav>
  );
}

For this round-trip to preserve data correctly, the journey has to be authored with three rules in mind:

  1. allowBack: "preserve-state" on every entry along the wizard path. A "rollback" entry restores its pre-transition snapshot on the way back, which discards anything the user typed on later steps. "preserve-state" keeps the accumulated state intact so downstream form data is still in state after the rewind.
  2. buildInput(state) on every entry. Each step re-derives its input from accumulated journey state on every (re-)entry. Without it, a form returned to via rewindTo (or even a single goBack) renders against the snapshot frozen at the original push — meaning data the user has since added is invisible to the form.
  3. Transition handlers stay pure routing. When the user clicks Next forward after editing, every intermediate transition fires again so each step revalidates against the new state — that's the point of forcing forward re-traversal (an early edit may change which options are valid downstream). This only works safely under the transition handler purity rules: handlers must be deterministic functions of state + output, and any side-effectful work belongs in a loading entry point so a replay doesn't double-fire it.

rewindTo is atomic: it walks the chain of frames it would leave and rejects the whole call if any one fails the back opt-in (transition allowBack: true + entry allowBack !== false). That means a non-rewindable step in the middle of the path can't strand the user halfway through — canRewindTo will return false and the breadcrumb chip stays disabled.

rewindTo is not a fast-forward. It only rewinds. To return to the most recent step after editing, the user walks forward via the normal Next flow — and that's intentional, not a missing feature. Re-running each intermediate transition is what lets the journey revalidate downstream choices against the user's edit. If you want a redo-without-handler-replay (the user clicked the breadcrumb by mistake), that's goForward — the future stack built by rewindTo matches what N successive goBack calls would have built, so a single goForward undoes one step at a time.

Transition handlers are pure and synchronous

  • No await.
  • No React hooks.
  • No store/service access.
  • No side effects.

If a transition needs to fetch data between steps, put the fetch inside a dedicated loading entry point on a module - the module fetches in useEffect and exits with the loaded data. Side effects live in the observation hooks (onTransition, onAbandon, onComplete, onAbort), which are free to be noisy.

Journey lifecycle

user triggers exit('X', output)
  → runtime checks step token matches (stale callbacks are dropped)
  → runs transition handler (pure)
  → commits state + step + history atomically
  → fires onTransition (definition first, then registration option)
  → if terminal: fires onComplete / onAbort
  → schedules persistence.save (serialized per instance)
  → JourneyOutlet re-renders with new step or terminal state

A step-token counter guards against double-click and stale callbacks: any exit() / goBack() captured at mount time is dropped silently if the current step has moved on.

Instance statuses

JourneyInstance.status runs through four values:

| Status | When | step | <JourneyOutlet> renders | | ------------- | ----------------------------------------------------------------------------------------------- | ------------------------ | ---------------------------------- | | 'loading' | Async persistence.load() is in flight (first paint after start()). | null | loadingFallback | | 'active' | The normal running state - step points at the module/entry currently on screen. | { moduleId, entry, … } | The step component | | 'completed' | Terminal. A transition returned { complete }. | null | null (after firing onFinished) | | 'aborted' | Terminal. A transition returned { abort }, the outlet unmounted, or runtime.end was called. | null | null (after firing onFinished) |

Terminal instances stay in memory (so late subscribers can read terminalPayload) until you call runtime.forget(id) / runtime.forgetTerminal().

Keys, idempotency, and "resume vs new"

When persistence is configured, runtime.start(journeyId, input) is idempotent per persistence key: two calls with inputs that resolve to the same keyFor return the same instanceId. This is the mechanism that turns "open the Alice onboarding tab" into "resume Alice's onboarding tab" on reload - no explicit resume() API is needed. See Persistence for the probe rules.

Without persistence, every start() mints a fresh instance. Two calls = two independent journeys that happen to share a journey id.

Authoring patterns

Patterns below are small, composable recipes - most real apps use two or three of them together.

Pattern - an exits const shared between the component and the descriptor

The canonical module shape: define exits once, consume them from the component (for a typed exit prop) and from the descriptor (for validation). No duplication.

// modules/profile/src/exits.ts
export const profileExits = {
  profileComplete: defineExit<{ customerId: string; hint: PlanHint }>(),
  cancelled: defineExit(),
} as const;
export type ProfileExits = typeof profileExits;
// modules/profile/src/ReviewProfile.tsx
export function ReviewProfile({
  input,
  exit,
}: ModuleEntryProps<{ customerId: string }, ProfileExits>) {
  /* exit('profileComplete', { customerId: input.customerId, hint }) is type-checked */
}
// modules/profile/src/index.ts
export default defineModule({
  id: "profile",
  version: "1.0.0",
  exitPoints: profileExits,
  entryPoints: {
    review: defineEntry({ component: ReviewProfile, input: schema<{ customerId: string }>() }),
  },
});

Note: defineModule is called without shell-level generics in this example. That keeps the descriptor's literal type (including the narrow entryPoints / exitPoints keys) preserved so the journey definition can cross-check transitions against typeof moduleDescriptor. A typed shell can still enforce AppDependencies / AppSlots via defineModule<AppDeps, AppSlots>() at the call site if desired - the tradeoff is that the narrow entry/exit types must be recovered via typeof in the journey's module map either way.

Pattern - a module exposing several entries

export default defineModule({
  id: "billing",
  version: "1.0.0",
  exitPoints: billingExits,
  entryPoints: {
    collect: defineEntry({ component: CollectPayment, input: schema<CollectInput>() }),
    startTrial: defineEntry({ component: StartTrial, input: schema<TrialInput>() }),
  },
});

The journey's transition map targets { module: 'billing', entry: 'collect' } or 'startTrial' - the discriminated StepSpec enforces that input matches the chosen entry.

Pattern - lazy entry-points (code-splitting per step)

For heavy steps (rich editors, charting libraries, large vendor bundles) declare lazy: () => import('./HeavyStep') instead of component:. The runtime wraps the resolved component in React.lazy + <Suspense> for you and exposes an idempotent preload() that the outlet calls during idle time (see auto-preload). This eliminates the per-entry LazyXxxStep.tsx wrapper consumers used to write to get past the descriptor's "must be a function" validation.

// modules/billing/src/index.ts
import { defineEntry, defineModule, schema } from "@modular-react/core";
import { billingExits } from "./exits.js";

export default defineModule({
  id: "billing",
  version: "1.0.0",
  exitPoints: billingExits,
  entryPoints: {
    collect: defineEntry({
      lazy: () => import("./CollectPayment.js"),
      fallback: <PaymentSkeleton />, // optional <Suspense fallback>
      input: schema<{ customerId: string; amount: number }>(),
    }),
  },
});

Rules:

  • Eager and lazy are mutually exclusive at the type level. Declaring both component and lazy on the same entry is a TypeScript error (and a validateModuleEntryExit issue — defense in depth). Declaring neither is also flagged.
  • fallback only on lazy entries. Eager entries don't suspend, so the field is typed never on EagerModuleEntryPoint. Trying to pass it would be confusing — make the trap visible at the type level.
  • Importer signature matches React.lazy. Standard () => import("./X") works (default export). The runtime also normalizes a module that exports the component directly, so () => Promise.resolve(MyComponent) works in tests.
  • The lazy import is memoized per entry-object identity via a process-local WeakMap in @modular-react/react. A descriptor is fetched at most once across all renders, hot reloads producing fresh descriptor objects get fresh wrappers, and StrictMode's double-mount is safe.
  • Manual prefetch is exposed via preloadEntry(entry) from @modular-react/react — useful for hover-prefetch UIs (onMouseEnter={() => preloadEntry(entry)}), navigation gestures, or warming a chunk from a useEffect that knows the user is about to advance.
  • Errors from the import propagate through Suspense and are caught by the outlet's existing StepErrorBoundary, going through onStepError (abort | retry | ignore) just like a component throw. A permanently-failing import re-throws the cached rejection on retry; the existing retryLimit budget applies.
  • SSR: React.lazy resolution is server-renderable in React 19+; the auto-preload effect is browser-only (no useEffect on the server).

Pattern - a loading entry point for async work

Transitions are pure and synchronous. When a step needs to fetch data between user actions, put the fetch inside a loading entry on the next module; that module fires an exit with the loaded data, and the journey transitions from that exit as usual. Use useWaitForExit to dispatch the exit when the fetch resolves — it owns the first-wins latch, unmount cleanup, and the step-token guard so the component stays declarative.

// modules/risk/src/LoadRiskReport.tsx
import { useWaitForExit } from "@modular-react/journeys";

export function LoadRiskReport({
  input,
  exit,
}: ModuleEntryProps<{ customerId: string }, RiskExits>) {
  useWaitForExit(exit, {
    subscribe: (resolve) => {
      const controller = new AbortController();
      api
        .fetchRiskReport(input.customerId, { signal: controller.signal })
        .then((report) => resolve("reportReady", { report }))
        .catch((err) => {
          if (controller.signal.aborted) return;
          resolve("failed", { reason: String(err) });
        });
      return () => controller.abort();
    },
  });

  return <LoadingSpinner label="Computing risk…" />;
}
// journey - same module -> entry -> exit nesting as the Quickstart example.
transitions: {
  account: {                  // `account` module id
    review: {                 // `review` entry on the account module
      needsRiskCheck: ({ output }) => ({   // exit fired by ReviewAccount when a risk check is needed
        next: { module: "risk", entry: "load", input: { customerId: output.customerId } },
      }),
    },
  },
  risk: {                     // `risk` module id
    load: {                   // `load` entry - the LoadRiskReport component shown above
      reportReady: ({ output, state }) => ({   // exit fired when the async fetch resolves
        state: { ...state, risk: output.report },
        next: { module: "decisions", entry: "choose", input: { risk: output.report } },
      }),
      failed: ({ output }) => ({ abort: { reason: "risk-check-failed", detail: output.reason } }),
    },
  },
}

Returning () => controller.abort() from subscribe tears down the in-flight request if the user clicks goBack before the fetch resolves. The runtime would drop a stale exit('reportReady', …) via step tokens anyway (see step tokens), and useWaitForExit's latch would drop a late resolve(...), but cancelling the network work avoids the spurious request. The next pattern leans on the same helper to combine a push channel, a polling fallback, and a deadline — that's where the helper earns its keep — but the single-fetch shape above is the canonical form for the simple case too.

Pattern - event-driven wait with timeout

When the next step depends on an event the backend pushes — a websocket message, an SSE frame, a long-poll resolution, anything outside the plain request/response cycle — the shape is the loading-entry pattern above with two additions: a polling fallback in case the push channel drops a message, and a deadline that decides what happens when nothing arrives. The wait still lives inside a step component that fires an exit; the journey owns what each exit means. useWaitForExit composes the three channels so the call site stays declarative.

// modules/projects/src/WaitForTranslationProcess.tsx
import { useWaitForExit } from "@modular-react/journeys";

export function WaitForTranslationProcess({
  input,
  exit,
}: ModuleEntryProps<{ projectId: string }, WaitExits>) {
  useWaitForExit(exit, {
    // Push channel — websocket, SSE, push notification: whichever transport
    // the app uses. The hook doesn't care.
    subscribe: (resolve) =>
      events.on(`translation-process:${input.projectId}`, (process) =>
        resolve("ready", { process }),
      ),
    // Polling fallback for missed push frames.
    poll: {
      intervalMs: 3000,
      check: async (resolve) => {
        const process = await api.getTranslationProcess(input.projectId);
        if (process) resolve("ready", { process });
      },
    },
    // Deadline arm — the journey decides whether that's a recoverable exit
    // ("editor opens with no preloaded process") or an abort.
    timeout: { ms: 60_000, fire: "timedOut" },
  });

  return <Spinner label="Preparing translation…" />;
}
// journey - both exits transition forward; the journey decides whether to
// branch on the timeout or treat both arms identically.
transitions: {
  projects: {
    waitForTranslationProcess: {
      ready: ({ output }) => ({
        next: { module: "editor", entry: "open", input: { process: output.process } },
      }),
      timedOut: () => ({
        next: { module: "editor", entry: "open", input: { process: null } },
      }),
    },
  },
}

What this shape gives you that a "submit, then useEffect(() => navigate(...), [...]) in shell code" hand-roll does not:

  • Single owner of the transition. useWaitForExit latches on the first dispatched exit and immediately tears down the losing channels; later resolutions are dropped. The hand-rolled equivalent has to maintain its own settled flag and make sure no effect re-fires after navigation — easy to get wrong when a router commit churns the navigate reference back through the effect's dep array, or when a stale "timed out" flag survives from a previous submission. Step tokens (see Errors, races, and edge cases) are a runtime-level backstop that protects both forms equally; the helper's contribution is making the local latch declarative.
  • Push / poll live outside of query callbacks. The push subscription and the polling fallback live inside useWaitForExit's effect, which is a fine place for side effects. The hand-rolled equivalent often ends up putting navigate(...) (or other side effects) inside a react-query refetchInterval callback — RQ schedules that callback during render-phase work and expects it to be pure, so a side effect there is what turns "advance once" into "re-fire on every commit."
  • Fresh latches per attempt. Each push of waitForTranslationProcess mounts a fresh component with a fresh latch. There is no journey-scoped equivalent of "the previous submission's timedOut flag is still set, so the next submission navigates instantly."

useWaitForExit's timeout has two forms: pass fire: "exitName" when the deadline dispatches a no-payload exit (the common case), or fire: (resolve) => resolve("fallback", payload) when the deadline needs to compute its output. The named form is constrained at the type level to exits whose schema declares a void output, so an exit that needs a payload won't compile in the named form.

If the journey would treat ready and timedOut identically (same next module + input shape, no state divergence), collapse them into one exit on the module's side. Two exits are only worth it when the journey's downstream — state writes, next module, telemetry — diverges between the success and timeout paths.

Pattern - optional exits (entries that don't emit every exit)

A module's exitPoints declares its full vocabulary. Individual entries don't have to emit every exit, and individual journeys don't have to handle every exit. If an entry fires an exit that has no handler in the current journey, the call is ignored and a dev-mode warning is logged - useful during refactors but usually a bug. Keep the exit vocabulary tight and prune unused exits.

Pattern - allowBack on an entry, allowBack: true on the transition

For goBack to appear in the component's props, both sides must opt in:

// module - declares the entry's back behaviour
entryPoints: {
  choose: defineEntry({ component: ChoosePlan, input: schema<ChooseInput>(), allowBack: "preserve-state" }),
}

// journey - opts that entry into back navigation
transitions: {
  plan: {                  // module id
    choose: {              // entry name on `plan` (matches the entry above)
      allowBack: true,     // journey-side opt-in
      // …exit handlers keyed by exit name…
    },
  },
}

Mismatched declarations are caught at resolveManifest() / resolve() time via validateJourneyContracts - the journey's allowBack: true with an entry that declared allowBack: false (or omitted it) is an aggregated validation error, not a runtime surprise.

Pattern - buildInput for re-entered forms

Without buildInput, a step's input is captured at first push and reused on every later entry — so a back-navigated form re-renders against the snapshot it was opened with, not the values accumulated by the user's later edits. buildInput flips that default: the runtime calls it on every step entry (initial start, forward push, goBack pop, resume-into-step) AND when a resume bumps state on the same step without advancing it. The returned value becomes the live input. Two excluded cases: (1) an { invoke } arm carrying state — the parent's form is paused while the child runs, so rebuilding the hidden input would be wasted work; buildInput re-fires naturally on the resume's { next }. (2) runtime.goForward — the redo restores a captured step whose input was already built against the same state being restored, so re-deriving would diverge from what the original transition produced.

interface ProjectState {
  readonly draftName: string;
  readonly draftSourceLang: string;
}

interface NameInput {
  readonly previousName: string;
}

const nameModule = defineModule({
  id: "name",
  version: "1.0.0",
  exitPoints: { next: defineExit<{ name: string }>() },
  entryPoints: {
    edit: defineEntry({
      component: NameForm,
      input: schema<NameInput>(),
      allowBack: "preserve-state",
      // Annotate `state` with the hosting journey's TState — the module
      // surface itself stays journey-agnostic (typed `unknown`).
      buildInput: (state: ProjectState) => ({ previousName: state.draftName }),
    }),
  },
});

Because buildInput owns the input, StepSpec makes the transition's input field optional for an entry that declares it — omit it entirely. Stamping a value still type-checks (handy mid-migration), but the runtime ignores it whenever buildInput is declared and, in debug mode, warns once when the stamped value would have differed from buildInput's output, so the divergence is observable. The optionality applies everywhere a step is produced: inline { next } literals, journey.start, defineTransition, and selectModule.

transitions: {
  source: {
    pick: {
      allowBack: true,
      next: ({ state, output }) => ({
        state: { ...state, draftSourceLang: output.lang },
        // `name.edit` declares `buildInput`, so `input` is optional here —
        // omit it; the runtime derives `previousName` from state on entry.
        next: { module: "name", entry: "edit" },
      }),
    },
  },
},

When the user back-navigates from a later step to this one, buildInput(state) runs again and the form sees the latest draftName — no React context, no useEffect-syncing local form state to journey state.

buildInput must be pure and synchronous; it runs on the runtime's hot path. A throw aborts the instance with { reason: "build-input-threw", moduleId, entry, error } (a member of JourneySystemAbortReason) rather than entering with a half-built or stale input — leaving the form mounted with the pre-throw cached input would silently mis-render against accumulated state, which is exactly the bug buildInput exists to fix. Use isJourneySystemAbort to narrow.

Notes

  • State typing is on the author. The module surface types state as unknown because modules don't know which journey hosts them. Two equivalent patterns for annotating it:

    // 1. Inline parameter annotation — terse, no extra import.
    buildInput: (state: ProjectState) => ({ previousName: state.draftName }),
    
    // 2. `buildInputFor<TState>()` wrapper — exported from `@modular-react/core`.
    //    Use this when the inline pattern trips your tsconfig
    //    (`strictFunctionTypes` can flag the narrowed `state` parameter as not
    //    assignable to the declared `(state: unknown) => TInput`), or when you
    //    just prefer named-generic positions over arrow-parameter annotations.
    buildInput: buildInputFor<ProjectState>()((state) => ({
      previousName: state.draftName,
    })),

    Neither pattern verifies that ProjectState matches the host journey's actual TState — modules are journey-agnostic, so the cross-cut isn't expressible at the entry-declaration site. A wrong TState annotation passes silently in both forms. Treat journey-level integration / harness tests as the safety net.

  • Step identity churns when buildInput is declared. Each entry allocates a fresh { moduleId, entry, input } object even when the derived input is structurally identical to the prior render's. Consumers relying on instance.step reference equality for memoization (onTransition listeners, custom useSyncExternalStore selectors) should compare by (step.moduleId, step.entry) or by a structural diff on step.input. Entries without buildInput keep the cache-on-push identity guarantee.

Testing tip — always pass options.modules for simulateJourney. Without it the runtime can't resolve module descriptors, so buildInput falls back to the cached handler-supplied input (silently degrading the behaviour under test) and validateJourneyContracts warnings about unbound modules surface in test output. The modules option is typed as Record<string, ModuleDescriptor<any, any, any, any>> — the bivariant any on TNavItem means a heterogeneous map like { name: nameModule, email: emailModule } passes structurally even when the host app narrows TNavItem to a custom action type. No as unknown as cast required.

simulateJourney(journey, input, {
  modules: { name: nameModule, email: emailModule },
});

Journey definition patterns

Pattern - branching on exit name

Most journeys branch by picking a different next step per exit name. StepSpec's discriminated union means input on each branch is type-checked against the target entry:

profile: {                                    // module id
  review: {                                   // entry name on `profile`
    profileComplete: ({ output, state }) => ({   // exit name -> branch into the `plan` module
      state: { ...state, hint: output.hint },
      next: { module: "plan", entry: "choose", input: { customerId: state.customerId, hint: output.hint } },
    }),
    readyToBuy: ({ output }) => ({               // different exit -> branch into `billing` instead
      next: { module: "billing", entry: "collect", input: { customerId: output.customerId, amount: output.amount } },
    }),
  },
},

Pattern - declared targets with defineTransition (auto-preload + narrowed return type)

Wrap a handler with defineTransition to declare every outcome it may take — both next-step destinations and terminal arms. Two effects from one declaration:

  1. Runtime — preload precision. <JourneyOutlet>'s default preload="precise" mode reads targets and warms exactly the declared next-step chunks during idle time, so navigating Next finds the chunk already cached.
  2. Type-level — the handler's return is constrained to the declared arms. Returning an arm that wasn't declared (e.g. abort when only next was declared) is a compile error.

targets accepts a mixed array of:

  • { module, entry } — same shape as next: minus the runtime-computed input. One per next-step candidate.
  • "complete" / "abort" / "invoke" — string sentinels for the terminal arms. Declaring "complete" permits { complete: ... } returns; "abort" permits { abort: ... }; "invoke" permits { invoke: ... } (the journey's invokes: field remains the closed-set declaration the runtime cycle-guards check against — the sentinel is just "this handler may invoke something").

The helper has two call shapes:

  • Curried (recommended)defineTransition<TModules, TState>() binds the journey's generics once. Handlers wrapped with the returned binder get targets autocompleted to valid step refs + sentinels and the handler's return narrowed to the declared arms.
  • BaredefineTransition({ targets, handle }) for one-off use. targets accepts any well-formed step-ref / sentinel without TModules-level checking; the handler's return is not contextually narrowed.

targets is mandatory on every defineTransition call — the wrapper's whole point is to enumerate the possible outcomes, and an empty/missing array would silently sit out of precise-mode preload while looking annotated. If you don't want to declare outcomes, use a bare function — the runtime invocation path is identical.

import { defineJourney, defineTransition } from "@modular-react/journeys";

// Bind the journey's generics once — every `transition({ ... })` call below
// gets autocomplete on `targets` and contextual narrowing on `next`. Naming
// mirrors `selectModule`: a descriptive verb for the binder, not an
// abbreviation (`tx` reads as "transaction" in most codebases).
const transition = defineTransition<OnboardingModules, OnboardingState>();

export const onboardingJourney = defineJourney<OnboardingModules, OnboardingState>()({
  // ...
  transitions: {
    profile: {
      review: {
        // Multi-target next: outlet preloads BOTH chunks during the idle
        // window after profile/review mounts. Handler return is narrowed
        // to `{ next: planChoose | billingCollect }` — returning `abort`
        // here would be a compile error.
        profileComplete: transition({
          targets: [
            { module: "plan", entry: "choose" },
            { module: "billing", entry: "collect" },
          ],
          handle: ({ output, state }) => ({
            state: { ...state, hint: output.hint },
            next:
              output.hint === "cheap"
                ? {
                    module: "plan",
                    entry: "choose",
                    input: { customerId: state.customerId, hint: output.hint },
                  }
                : {
                    module: "billing",
                    entry: "collect",
                    input: { customerId: state.customerId, amount: 0 },
                  },
          }),
        }),
        // Mixed: handler may advance OR abort. Both arms are declared.
        review: transition({
          targets: [{ module: "plan", entry: "choose" }, "abort"],
          handle: ({ output }) =>
            output.ok
              ? {
                  next: {
                    module: "plan",
                    entry: "choose",
                    input: { customerId: "c", hint: "cheap" },
                  },
                }
              : { abort: { reason: "rejected" } },
        }),
        // Terminal-only: declaring `"abort"` lets the catalog harvester
        // surface the abort flag without an AST walk over the handler body,
        // and constrains the return to just the abort arm at compile time.
        cancelled: transition({
          targets: ["abort"],
          handle: () => ({ abort: { reason: "user-cancelled" } }),
        }),
      },
    },
  },
});

Why explicit declarations rather than inferring from the handler body? Handler bodies are dynamic (next: cond ? A : B) and may have side effects, so the runtime can't safely run them speculatively to enumerate destinations. One declarative line per wrapped transition is the trade-off — and it doubles as the catalog's authoritative outcome map (no AST walking for aborts / completes flags either).

Bare-function handlers still work. The runtime invocation path is identical, and they sit out of precise-mode preload (preload="aggressive" is the fallback). Migrate handlers that fan out to heavy steps first; everything else stays as-is.

Pattern - branching on state/output inside a handler

Handlers are plain functions - branch with if / switch on output or state. Return whichever TransitionResult makes sense.

review: {                                  // entry name (the surrounding module key is omitted for brevity)
  done: ({ output, state }) =>             // exit name fired by the `review` entry
    output.needsKyc
      ? { next: { module: "kyc", entry: "collect", input: { customerId: state.customerId } } }
      : { complete: { reason: "ok" } },
}

Pattern - exhaustive state-driven module dispatch (selectModule)

When a transition needs to dispatch to one of N modules based on a discriminator (a value picked earlier in the flow, a kind from the previous module's output, etc.), a hand-written switch works but loses two things: exhaustiveness when the discriminator's union grows, and per-branch input narrowing without per-branch ceremony. selectModule<TModules>() collapses both into one declarative call:

import { selectModule } from "@modular-react/journeys";

const select = selectModule<IntegrationModules>();

chooser: {                                  // module id - the picker module
  pick: {                                   // entry name on `chooser` - renders the integration list
    chosen: ({ output, state }) => ({       // exit fired when the user picks an integration
      state: { ...state, selected: output.kind },
      next: select(output.kind, {
        // Each key below is a target module id; `entry` / `input` are checked against that module.
        github:     { entry: "configure", input: { workspaceId: state.workspaceId, repo: output.repo } },
        strapi:     { entry: "configure", input: { workspaceId: state.workspaceId, url: output.url } },
        contentful: { entry: "configure", input: { workspaceId: state.workspaceId, spaceId: output.spaceId } },
      }),
    }),
  },
},

The cases object is Record<TKey, …>, so adding a new value to the discriminator's union without a matching branch is a compile error. Each case's entry is type-narrowed against that module's entryPoints; input is checked against that entry - pasting a strapi-shaped input under the github key fails at the call site.

Limit. The discriminator key must equal the target module id. When they differ (e.g. tier: "free" | "paid" dispatching to module ids trial-onboarding / billing-onboarding), fall back to a switch returning next per branch - the helper's value is the exhaustiveness + per-branch typing, not the lookup itself.

Pattern - fallback dispatch (selectModuleOrDefault)

When most discriminator values funnel into a generic module and only a few warrant their own dedicated step, use the sibling selectModuleOrDefault - it accepts a partial cases map plus an explicit fallback StepSpec:

import { selectModuleOrDefault } from "@modular-react/journeys";

const select = selectModuleOrDefault<IntegrationModules>();

chooser: {                                  // module id - the picker module
  pick: {                                   // entry name on `chooser`
    chosen: ({ output, state }) => ({       // exit fired with the chosen integration kind
      state: { ...state, selected: output.kind },
      next: select(
        output.kind,
        {
          // Keys are target module ids. Only github + strapi earn dedicated configure steps.
          github: { entry: "configure", input: { workspaceId: state.workspaceId, repo: "..." } },
          strapi: { entry: "configure", input: { workspaceId: state.workspaceId } },
        },
        // contentful, notion, future kinds - all flow through the
        // generic configure step.
        { module: "generic", entry: "configure", input: { workspaceId: state.workspaceId, kind: output.kind } },
      ),
    }),
  },
},

It's a separate function, not a third argument on selectModule, so the exhaustive call site is visually distinct from the fallback-allowed one - adding a third argument later can't silently disable the missing-branch compile error.

When to prefer which. Pick selectModule (exhaustive) if every discriminator value gets its own dedicated module - the missing-case error is the whole point. Pick selectModuleOrDefault (fallback) when you have a real catch-all module: most kinds funnel through generic shape, only a handful warrant tailored UI. A third-party plugin system that lets new integration kinds appear at runtime always wants the fallback form, since the journey can't know every kind ahead of time.

Pairing with slot-driven discovery

selectModule / selectModuleOrDefault plays well with the slots system for the common "chooser → specific" shape:

  • Each module contributes itself to a shared slot (e.g. slots: { integrations: [{ id: "github", label: "GitHub", … }] }).
  • The chooser module reads useSlots<AppSlots>().integrations and renders one row per contribution - staying agnostic of which integrations exist.
  • The journey's chosen transition uses selectModule(Or) to dispatch on the picked id.

Slots drive presentation (dynamic, discoverable); the journey owns dispatch (typed, statically declared). See examples/react-router/integration-setup-journey/ for an end-to-end example with both forms exercised by Playwright.

Pattern - wildcard transitions (wildcardTransitions)

Cross-cutting outcomes - cancelled, error, back, signedOut - tend to be emitted by many modules and handled the same way. Rather than copy the same handler under every transitions[mod][entry] block, declare it once on wildcardTransitions. Two precision tiers:

defineJourney<Modules, State>()({
  // …id, version, initialState, start…
  transitions: {
    // exact handlers — one per [module][entry][exit] triple.
    profile:  { review:  { approved: ({ output }) => ({ next: ... }) } },
    billing:  { confirm: { approved: ({ output }) => ({ complete: ... }) } },
  },
  wildcardTransitions: {
    // tier 2 — module unknown, entry + exit known.
    // Fires when no exact handler matches AND the active step's entry name is "review".
    byEntryAndExit: {