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

@rxova/journey-core

v1.0.1

Published

Journey core state machine.

Readme

@rxova/journey-core

Typed, framework-independent state machines for multi-step product flows.

Journey owns current position, context, realized history, lifecycle status, terminal outcomes, and observable async transition state. Linear and graph definitions run on the same small runtime.

Install

npm install @rxova/journey-core

Linear quickstart

import { createLinearJourney } from "@rxova/journey-core";

type CheckoutStepId = "account" | "shipping" | "review";
type CheckoutContext = {
  email: string;
  shippingId: string | null;
};
type CheckoutTerminationPayloads = {
  complete: { orderId: string };
  terminate: { reason: "cancelled" | "expired" };
};

const checkout = createLinearJourney<CheckoutStepId, CheckoutContext, CheckoutTerminationPayloads>({
  steps: [
    { id: "account", metadata: { title: "Account" } },
    { id: "shipping", metadata: { title: "Shipping" } },
    { id: "review", metadata: { title: "Review" } },
  ],
  context: {
    email: "",
    shippingId: null,
  },
});

checkout.context.update((context) => ({
  ...context,
  email: "[email protected]",
}));

await checkout.navigate.goToNextStep();

The first declared step is initial. String steps are shorthand when metadata and hooks are not needed:

createLinearJourney({
  steps: ["account", "shipping", "review"],
  context: {},
});

The optional third generic groups payloads passed to controls.complete and controls.terminate. It also types the corresponding snapshot.machine.outcome. Omit it when terminal payload typing is not needed.

Transactional navigation

Pass work to next or previous navigation when an operation must succeed before movement:

const result = await checkout.navigate.goToNextStep({
  run: async ({ snapshot }) => submitShipping(snapshot.context),
  commit: ({ result: shipping, updateContext }) => {
    updateContext((context) => ({
      ...context,
      shippingId: shipping.id,
    }));
  },
});

if (!result.ok) {
  console.error(result.reason, result.error);
}

run is awaited while the source remains current. If it throws, rejects, or times out, neither position nor context changes. commit is synchronous; its context updates publish atomically with the destination.

goToPreviousStep() accepts the same work contract.

Async state

Use snapshot.transition.pending as the normal UI-level loading flag:

const snapshot = checkout.getSnapshot();
continueButton.disabled = snapshot.transition.pending;

For diagnostics and richer feedback, snapshot.transition identifies the working, leaving, or entering phase, source, and destination. snapshot.currentStep.async records loading, success, and error state for the current entry.

Only one navigation settles at a time. Concurrent calls resolve with { ok: false, reason: "transitioning" }.

Lifecycle effects

onLeave and onEnter are awaited post-commit effects. They cannot cancel or roll back movement:

const machine = createLinearJourney({
  context: { receiptId: null as string | null },
  steps: [
    {
      id: "payment",
      onLeave: ({ snapshot }) => analytics.track("payment_left", snapshot.context),
    },
    {
      id: "receipt",
      onEnter: async ({ snapshot, updateContext }) => {
        const receipt = await loadReceipt(snapshot.context);
        updateContext((context) => ({ ...context, receiptId: receipt.id }));
      },
    },
  ],
});

Use navigation work for validation or submission that must prevent movement. Use hooks for cleanup, analytics, and destination setup after the move has committed.

Navigation and history

await checkout.navigate.goToNextStep();
await checkout.navigate.goToPreviousStep();
await checkout.navigate.goToPreviousStep(2);
await checkout.navigate.goToLastVisitedStep();

Linear history behaves like a browser timeline. Going back preserves the future; appending a new destination from an older position replaces the abandoned future.

goToStepById(id) is an intentional escape hatch for occasional direct jumps in an otherwise ordered flow:

await checkout.navigate.goToStepById("review");

It is ungated and can reach any declared linear step. When named jumps, branching, or guarded destinations become normal flow behavior, move the definition to graph mode.

Completion and termination

Reaching the last linear step does not complete the machine. A final screen is a position; completion is an explicit product outcome:

checkout.controls.complete({ orderId: "order-42" });

checkout.getSnapshot().machine.outcome;
// { type: "completed", payload: { orderId: "order-42" } }

Use controls.terminate(payload?) for an unsuccessful or cancelled terminal outcome. Navigation is rejected after either terminal state until controls.restart() begins a fresh run.

Snapshot and subscriptions

All changing state lives in one immutable snapshot:

const snapshot = checkout.getSnapshot();

snapshot.status; // "idle" | "running" | "paused" | "completed" | "terminated"
snapshot.context;
snapshot.currentStep?.id;
snapshot.currentStep?.index;
snapshot.history.timeline;
snapshot.history.canGoBack;
snapshot.transition.pending;
snapshot.machine.outcome;

Subscribe to every committed snapshot, or to a named lifecycle event:

const stop = checkout.subscriptions.subscribe(() => render(checkout.getSnapshot().currentStep?.id));

checkout.subscriptions.subscribeEvent("navigationBlocked", ({ reason, error }) => {
  reportNavigationFailure(reason, error);
});

stop();

Graph mode

Use createGraphJourney when named events, guards, or branches choose destinations. Graph machines add typed send(type, payload?), available events and targets in the snapshot, and transition-level post-commit effects.

Candidates take an optional label, which names the edge in timeout and error messages, in the transition argument every step hook receives, and in the structure view plugins read — several candidates on one event differ only by guard, so event and to do not tell them apart. They also take an optional timeoutMs, as does a declared work entry, overriding defaultTimeoutMs for that one edge so a single slow third-party call does not set the budget for the whole graph.

For larger graphs, withGraphTypes<Bag>() pins the types once and GraphStep<Bag> lets each step live in its own file, and defineWork<Bag, "EVENT">() reads a work entry's result type off its run instead of restating it in the bag. See the Graph guide.

Connectors

Connectors adapt optional third-party libraries to existing Core primitives without attaching a plugin to the machine. The Immer connector turns a synchronous producer into a context updater:

pnpm add immer
import { immerConnector } from "@rxova/journey-core/connectors/immer";

machine.context.update(
  immerConnector<CheckoutContext>((draft) => {
    draft.cart.items.push(item);
    draft.cart.total += item.price;
  }),
);

It also works with the updateContext passed to hooks and transactional commits. Immer remains an optional peer, so consumers that do not import this connector do not install or bundle it. See the Immer connector guide for replacement recipes, freezing, draftability, and transactional behavior.

Plugins

Built-in plugins are separately imported and observe the machine through a read-only host:

import { createLinearJourney } from "@rxova/journey-core";
import { createAnalyticsPlugin } from "@rxova/journey-core/plugins";

const machine = createLinearJourney(definition, {
  plugins: [
    createAnalyticsPlugin({
      track: (event) => analytics.track(event.name, event.payload),
    }),
  ],
});

Persistence, analytics, replay, and execution paths each have dedicated export paths and documentation. analyzeStructure(definition) checks a graph for unreachable steps, shadowed transitions and cycles without creating a machine.

Documentation

License

MIT