Rxova
Skip to content

Provider and Hooks API

This page focuses on React ownership and subscription behavior. Runtime navigation, lifecycle, and snapshot semantics come directly from Core; see Core API and Lifecycle.

createLinearJourney(definition, options?) captures Core’s linear definition shape — { context, steps }, plus an optional name used for the Provider’s React DevTools displayName — and creates one standalone machine right in the factory, at module scope. It returns a bundle around that machine: machine, Provider, StepRenderer, the reactive hooks useSnapshot, useSelector, useStep, useContextSelector, and useEventEffect, the forward gate useStepHandler, and the verbatim delegates controls, navigate and updateContext — each pre-bound to the definition’s context and step-id types. A bare string in steps is shorthand for { id }; a config object also carries metadata. Core’s per-step onEnter/onLeave hooks are not accepted in this tier — see Enter and leave are effects.

import { createLinearJourney } from "@rxova/journey-react";
type CheckoutContext = {
shipping: ShippingDetails | null;
shippingId: string | null;
};
const initialContext: CheckoutContext = { shipping: null, shippingId: null };
const checkout = createLinearJourney({
name: "checkout",
context: initialContext,
steps: ["account", { id: "shipping", metadata: { title: "Shipping" } }, "review"]
});

TContext is inferred from definition.context — annotate the value, as above, rather than casting — and the step-id union is inferred from the steps tuple; call sites never pass generics. See TypeScript Types for the inference story.

Declared metadata surfaces at snapshot.currentStep.metadata while the step is current; there is no separate per-step metadata lookup. Component-scoped async work belongs in useStepHandler().

This tier’s step configs carry no onEnter/onLeave. <StepRenderer> keys the active view by step id, so the step’s own component mounts when the step is entered and unmounts when it is left — a useEffect with a cleanup already says both, and unlike a hook running inside Core it can read component state, props and React context:

function Review() {
React.useEffect(() => {
analytics.track("review_entered");
return () => analytics.track("review_left");
}, []);
// …
}

Use useEventEffect("stepEnter" | "stepLeave", …) instead when the observer is not the step’s own view — a header that logs every move, say. Core keeps onEnter/onLeave for machines driven outside React; passing either here is a type error.

The second argument is Core’s runtime options, verbatim and frozen per bundle: startAt, persist, plugins, defaultTimeoutMs, onListenerError, and autoStart — which is three-way in this tier: omitted (the default) starts the machine when the bundle’s first Provider or hook mounts, true starts it eagerly inside the factory, and false waits for checkout.machine.controls.start(). See Bundle options. The startAt option starts the journey directly at that step: earlier steps are never entered or visited, the timeline begins as [startAt], and controls.restart() returns to it. An unknown startAt id throws at creation.

The machine outlives any component: every hook closes over it and works with or without the Provider, non-React code drives it via checkout.machine, checkout.navigate, and checkout.updateContext(...), and unmounting disposes nothing. The consequences are worth stating plainly: all Providers and hooks share the one machine; journey state survives unmounts and remounts, so reset explicitly — controls.restart() from a terminal status, terminate() first when mid-flight; and under SSR a module-scope machine is shared across requests. For per-mount or per-request isolation, own the bundle with useJourney() (see Own a bundle inside a component), or own a Core machine yourself and read it with React.useSyncExternalStore (see Caller-owned machines).

The Provider takes exactly two props — views and children — and exists to hand the views to <StepRenderer>, the one piece that must render inside it. views maps each declared step id to what that step renders; StepRenderer renders the active step’s view wherever you place it, so headers and footers are ordinary siblings. Step config lives in the definition, never in views — a view supplies markup, nothing else.

<checkout.Provider
views={{
account: <Account />,
shipping: <Shipping />,
review: <Review />
}}
>
<Progress />
<checkout.StepRenderer fallback={<p>Starting…</p>} />
<Controls />
</checkout.Provider>

views is typed as JourneyViews<TStepId>{ [K in TStepId]: ReactNode } — so coverage is checked entirely at compile time: a missing key and an undeclared key are both TS errors, and there is no runtime assertion. A null view value is legal and renders nothing. StepRenderer shows its optional fallback whenever no view can render: while the machine is idle (autoStart: false before start(), when currentStep is null) or, in plain JS, when the active id has no key. Values are elements, not component types, so props and wrappers stay inline. The active view is keyed by its step id: every entry into a step mounts the view fresh, so local component state does not survive leaving the step.

There are no other Provider props. Starting position and runtime configuration are factory options, step config lives in the definition, and events are observed with useEventEffect in a component — or with machine.subscriptions at module scope, no React involved:

checkout.machine.subscriptions.subscribeEvent("statusChange", ({ current }) => {
if (current === "completed") analytics.track("checkout completed");
});

Reactive hooks: useSnapshot(), useSelector(), useStep(), useContextSelector(), and useEventEffect()

Section titled “Reactive hooks: useSnapshot(), useSelector(), useStep(), useContextSelector(), and useEventEffect()”

The reactive hooks subscribe to the bundle’s machine directly — none of them needs a Provider above it:

function Controls() {
const snapshot = checkout.useSnapshot();
const navigate = checkout.machine.navigate;
const currentStep = snapshot.currentStep;
if (currentStep === null) return null; // idle: autoStart: false, not started yet
return (
<nav>
<p>
{currentStep.id} ({currentStep.index + 1} / {snapshot.steps.totalSteps})
</p>
<button disabled={currentStep.isFirstStep} onClick={() => void navigate.goToPreviousStep()}>
Back
</button>
<button disabled={snapshot.transition.pending} onClick={() => void navigate.goToNextStep()}>
Continue
</button>
</nav>
);
}

Every read is a snapshot field: snapshot.currentStep.id/.index/.isFirstStep/.isLastStep/ .isFirstTimeVisit/.metadata/.async, snapshot.steps.totalSteps/.stepOrder, snapshot.history.visited, snapshot.status, snapshot.transition.pending/.phase, and snapshot.context. snapshot.currentStep is null while the machine is idle — exactly as in the graph tier. See Snapshot for the complete contract.

const isLoading = checkout.useSelector((snapshot) => snapshot.transition.pending);
const step = checkout.useStep();
const email = checkout.useContextSelector((context) => context.email);
checkout.useEventEffect("stepEnter", ({ from, to, direction }) =>
analytics.track("step", { from, to, direction })
);

Prefer useSelector when a component needs only one changing value; the optional equality function controls when React re-renders, and selectors should be pure and not mutate snapshot data. useStep() returns the whole currentStep — id, metadata, async state — or null while idle. useContextSelector(selector, equalityFn?) is useSelector narrowed to the context: the selector is required, so useContextSelector((context) => context) is the explicit way to ask for the whole object and re-render on every context write.

useEventEffect requires an exact Core subscription name and receives its exact payload; the listener reference can change without forcing a new subscription, and the subscription lasts for the component’s lifetime. stepEnter carries { snapshot, from, to, direction }, where direction is "forward" | "backward" | "jump" by intent: only goToNextStep and goToPreviousStep report "forward"/"backward"; the initial entry, goToStepById, goToStepByIndex, and goToLastVisitedStep report "jump". stepLeave carries { snapshot, from, to }, statusChange carries { snapshot, previous, current }, and error carries { snapshot, error, phase, stepId }.

const { machine, controls, navigate } = checkout;
controls.pause();
controls.resume();
await navigate.goToPreviousStep();
await checkout.navigate.goToNextStep();
checkout.updateContext((context) => ({ ...context, dirty: true }));

These are plain bundle properties, not hooks: the machine’s command groups are frozen objects, the same reference on every render, so reading them can neither subscribe nor re-render. Every command is a machine group: machine.navigate.* (including linear goToStepByIndex), machine.controls.*, machine.context.update(updater), and machine.async.clearError(); navigation methods return Core NavigationResult values. controls, navigate and updateContext are the bundle’s verbatim shortcuts to machine.controls, machine.navigate and machine.context.update — callable from React or anywhere else. Integrations attach to the machine directly:

React.useEffect(() => attachJourneyDevtools(checkout.machine, { mutationsEnabled: false }), []);

See Machine API for the complete contracts.

A step component can register transactional work that must succeed before forward navigation. The step id is explicit — the first argument, typed to the declared union:

function ShippingStep() {
checkout.useStepHandler("shipping", {
run: ({ snapshot }) => shippingApi.save(snapshot.context.shipping),
commit: ({ result, updateContext }) => {
updateContext((context) => ({
...context,
shippingId: result.id
}));
}
});
return <ShippingForm />;
}

The registration lasts while the calling component is mounted (it unregisters on unmount), and the bundle’s goToNextStep() runs it when no explicit work is passed. The registry is the bundle’s, not the machine’s — Core keeps exactly one channel for pre-move async, goToNextStep(work), and register-on-mount is a React lifetime concern. The bundle’s navigate and its machine both expose the wrapper, so every path reached through the bundle honours the handlers. run happens before movement; commit publishes its updates atomically with movement. A failed run leaves the source step and context in place, and the error lands in snapshot.currentStep.async.error until machine.async.clearError(); snapshot.transition.pending is true while the work is pending. The gate is forward-only: timeline moves and goToStepById bypass it, and it never fires on the final step (goToNextStep on the last step never auto-completes).

The captured definition is Core’s own LinearJourneyDefinition shape. When an ordered flow grows into named graph events, the context and steps carry over to a graph definition unchanged — each step gains an on declaring the events it answers.

createGraphJourney(definition, options?) returns the linear bundle’s twin with graph verbs — the same standalone machine created by the factory at module scope, send where linear has navigate gating. Every hook closes over the machine and works with or without the Provider, and non-React code drives it through checkout.machine, checkout.send(...), and checkout.updateContext(...) — verbatim delegates. autoStart behaves identically here: omitted starts the machine on the first mount, true starts it in the factory, false waits for checkout.machine.controls.start().

const checkout = createGraphJourney(checkoutDefinition);
<checkout.Provider
views={{
cart: <Cart />,
shipping: <Shipping />,
review: <Review />,
done: <Done />
}}
>
<ProgressHeader />
<checkout.StepRenderer fallback={<MissingStep />} />
<Toolbar />
</checkout.Provider>;

The Provider exists only to hand views to <StepRenderer>, which is the one piece that must render inside it. views is JourneyViews<TStepId>{ [K in TStepId]: ReactNode }, the same contract as the linear tier: exhaustively type-checked, element values so props and wrappers stay inline. StepRenderer renders the active step’s view wherever you place it (headers and footers are ordinary siblings), keys it by step id so each entry mounts the view fresh, and shows its optional fallback while the machine is idle.

The standalone-machine consequences match the linear tier: all Providers and hooks share the one machine; journey state survives unmounts and remounts, so reset explicitly — controls.restart() from a terminal status, terminate() first when mid-flight; and under SSR the module-scope machine is shared across requests. For per-mount or per-request isolation, own a Core machine yourself and read it with React.useSyncExternalStore (see Caller-owned machines).

const snapshot = checkout.useSnapshot();
const stepId = checkout.useSelector((value) => value.currentStep?.id);

Use the snapshot when several related values must be rendered together. Use a selector for leaf components that should not re-render on unrelated context or plugin changes. Neither needs a Provider above it — they subscribe to the bundle’s machine directly.

Graph useStep(), useContextSelector(), and useEventEffect()

Section titled “Graph useStep(), useContextSelector(), and useEventEffect()”
const step = checkout.useStep();
const attempts = checkout.useContextSelector((context) => context.attempts);
checkout.useEventEffect("navigationBlocked", ({ reason, error }) => {
report(reason, error);
});

useStep() returns the whole currentStep — id, metadata, async state — or null while the machine is idle. useContextSelector(selector, equalityFn?) is useSelector narrowed to the context; the selector is required. useEventEffect requires an exact Core subscription name and receives its exact payload; the listener reference can change without forcing a new subscription, and the subscription lasts for the component’s lifetime.

const { machine, controls } = checkout;
const navigate = checkout.machine.navigate;
controls.pause();
controls.resume();
await navigate.goToPreviousStep();
await navigate.goToLastVisitedStep();
await checkout.send("continue");
checkout.updateContext((context) => ({ ...context, dirty: true }));

The accessors return the machine and its stable grouped methods without subscribing — they never cause a re-render. send is narrowed to the event union inferred from the definition, and both send and updateContext are plain functions on the bundle, callable from React or anywhere else. Integrations attach to checkout.machine directly — no Provider or ref involved.

Per-mount or per-request isolation, tests, and integrations that must own the machine’s lifecycle use a Core machine directly. There is no separate React hook package for this — React’s own useSyncExternalStore is the whole bridge:

import React from "react";
import { createLinearJourney } from "@rxova/journey-core";
export const machine = createLinearJourney({ context: initialContext, steps });
// The machine is a module-scope singleton, so this adapter is a stable plain
// function — useSyncExternalStore never resubscribes on it.
const subscribe = (onStoreChange: () => void) => machine.subscriptions.subscribe(onStoreChange);
export const useJourneySnapshot = () =>
React.useSyncExternalStore(subscribe, machine.getSnapshot, machine.getSnapshot);

machine.getSnapshot is a stable bound function, so it serves as both the client and server getter unchanged. Observe events in an effect — subscribeEvent returns its unsubscribe:

React.useEffect(
() => machine.subscriptions.subscribeEvent("stepEnter", ({ from, to }) => console.log(from, to)),
[]
);

You own start and disposal: create the machine where its lifetime belongs (module, request, mount, or test), and call machine.dispose() when that owner goes away. For typing wrappers around a caller-owned machine, @rxova/journey-react exports the structural helpers AnyJourneyMachine, SnapshotOf, ContextOf, StepIdOf, and EventPayloadOf. The six react-plugin-* examples author machines this way.