Most wizard libraries bury your business logic inside UI components. Pathwrite separates them: your flow is a plain TypeScript object you can develop, test, version, and share — completely independently of any framework.
Every guard, hook, and navigation path is tested in pure Node. The engine runs identically in a browser, a test runner, and React Native.
React, Vue, Angular, Svelte, SolidJS, and React Native. Each adapter provides an idiomatic hook, composable, or injectable — plus an optional PathShell UI.
Your PathDefinition is a plain TypeScript object. Package it as an npm module, version it independently, and import it into every app — web and mobile.
A PathDefinition is a plain TypeScript object — no framework imports, no component coupling, no DOM.
Because it has no dependencies, you get four things that are impossible when logic lives inside components.
new PathEngine(). No DOM. No component mounting. No Playwright.No other package combines all of these.
| Pathwrite | XState | react-step-wizard | Formik / RHF | |
|---|---|---|---|---|
| Framework-agnostic core | ✓ | ✓ | ✗ | ✗ |
| React + Vue + Angular + Svelte + Solid | ✓ | 2 of 5 | React only | React only |
| React Native (iOS & Android) | ✓ | ✗ | ✗ | ✗ |
| Built-in persistence | ✓ | ✗ | ✗ | ✗ |
| Navigation guards | ✓ | ✓ | ✗ | ✗ |
| Field-level validation | ✓ | ✗ | ✗ | ✓ |
| Conditional step skipping | ✓ | ✓ | ✗ | ✗ |
| Sub-paths / nested flows | ✓ | ✓ | ✗ | ✗ |
| Optional shell UI | ✓ | ✗ | ✓ | ✗ |
| Testable without UI | ✓ | ✓ | ✗ | ✗ |
| Wizard-specific API | ✓ | ✗ | ✓ | ✗ |
| Learning curve | Minutes | Days | Minutes | Hours |
Pathwrite has a first-class React Native adapter. The same PathDefinition
that powers your web checkout runs on iOS and Android too — guards, sub-paths,
persistence, and all.
PathShell adapts to native layout automatically. Or go headless with
usePath() and build your own native UI.
The demo to the right is a real React Native app compiled to web via Expo. It runs the same engine that powers all the web demos.
How this works →The same job-application flow — from pure TypeScript to headless tests to React, Vue, Angular, Svelte, SolidJS, and React Native.
// Pure TypeScript — no React, no Vue, no Angular.
// This file is the entire workflow. Ship it as its own npm package.
import type { PathDefinition } from "@daltonr/pathwrite-core";
export interface ApplicationServices {
checkEligibility(years: number): Promise<{ eligible: boolean; reason?: string }>;
requiresCoverLetter(roleId: string): Promise<boolean>;
}
export function createApplicationPath(svc: ApplicationServices) {
return {
id: "job-application",
steps: [
{ id: "role", title: "Role", canMoveNext: ({ data }) => !!data.roleId },
{ id: "experience", title: "Experience" },
{
id: "eligibility", title: "Eligibility",
canMoveNext: async ({ data }) => {
const r = await svc.checkEligibility(Number(data.yearsExperience));
return r.eligible ? true : { allowed: false, reason: r.reason! };
},
},
{
id: "cover-letter", title: "Cover Letter",
shouldSkip: async ({ data }) => !(await svc.requiresCoverLetter(data.roleId)),
},
{ id: "review", title: "Review" },
],
} satisfies PathDefinition<ApplicationData>;
}
Guards, async conditions, and lifecycle hooks — all plain TypeScript. Zero UI coupling.
// No framework. No DOM. No component mounting.
// Every guard, route, and conditional step is testable as pure logic.
import { PathEngine } from "@daltonr/pathwrite-core";
// relative import — these tests live inside the my-app-workflows package itself
import { createApplicationPath } from "../src/job-application";
const fast = {
checkEligibility: async (y) => y >= 2 ? { eligible: true } : { eligible: false, reason: "Min 2 years required" },
requiresCoverLetter: async (id) => id === "eng" || id === "data",
};
it("blocks navigation when experience < 2 years", async () => {
const engine = new PathEngine();
await engine.start(createApplicationPath(fast), { roleId: "eng", yearsExperience: "1" });
await engine.next(); // → experience
await engine.next(); // → eligibility
await engine.next(); // guard blocks — stays on eligibility
expect(engine.snapshot()?.stepId).toBe("eligibility");
expect(engine.snapshot()?.blockingError).toBeTruthy();
});
it("skips cover letter for non-engineering roles", async () => {
const engine = new PathEngine();
await engine.start(createApplicationPath(fast), { roleId: "pm", yearsExperience: "5" });
await engine.next(); await engine.next(); await engine.next();
expect(engine.snapshot()?.stepId).toBe("review"); // cover-letter was skipped
});
CI runs these in milliseconds. No browser, no Playwright, no mounted components.
import { PathShell } from "@daltonr/pathwrite-react";
import { createApplicationPath } from "my-app-workflows"; // ← shared package
import { apiServices } from "./services";
export default function JobApplication() {
return (
<PathShell
path={createApplicationPath(apiServices)}
initialData={{ roleId: "", yearsExperience: "" }}
onComplete={handleSubmit}
steps={{
role: <RoleStep />,
experience: <ExperienceStep />,
eligibility: <EligibilityStep />,
"cover-letter": <CoverLetterStep />,
review: <ReviewStep />,
}}
/>
);
}
The workflow logic stays in my-app-workflows. This file is UI only.
<script setup>
import { PathShell } from "@daltonr/pathwrite-vue";
import { createApplicationPath } from "my-app-workflows"; // ← shared package
import { apiServices } from "./services";
const path = createApplicationPath(apiServices);
</script>
<template>
<PathShell :path="path" :initial-data="{ roleId: '', yearsExperience: '' }"
@complete="handleSubmit">
<template #role><RoleStep /></template>
<template #experience><ExperienceStep /></template>
<template #eligibility><EligibilityStep /></template>
<template #cover-letter><CoverLetterStep /></template>
<template #review><ReviewStep /></template>
</PathShell>
</template>
Same workflow package import. Vue-idiomatic slot syntax replaces JSX — the business logic is unchanged.
// job-application.component.ts
import { createApplicationPath } from "my-app-workflows"; // ← shared package
import { ApiServices } from "./api.service";
export class JobApplicationComponent {
path = createApplicationPath(inject(ApiServices));
}
// job-application.component.html
<pw-shell [path]="path" [initialData]="{ roleId: '', yearsExperience: '' }"
(completed)="handleSubmit($event)">
<ng-template pwStep="role"><app-role-step /></ng-template>
<ng-template pwStep="experience"><app-experience-step /></ng-template>
<ng-template pwStep="eligibility"><app-eligibility-step /></ng-template>
<ng-template pwStep="cover-letter"><app-cover-letter-step /></ng-template>
<ng-template pwStep="review"><app-review-step /></ng-template>
</pw-shell>
Angular DI injects real services at runtime. The workflow package never imports Angular.
<script>
import { PathShell } from "@daltonr/pathwrite-svelte";
import { createApplicationPath } from "my-app-workflows"; // ← shared package
import { apiServices } from "./services";
const path = createApplicationPath(apiServices);
</script>
<PathShell {path} initialData={{ roleId: "", yearsExperience: "" }}
oncomplete={handleSubmit}>
{#snippet role()}<RoleStep />{/snippet}
{#snippet experience()}<ExperienceStep />{/snippet}
{#snippet eligibility()}<EligibilityStep />{/snippet}
{#snippet coverLetter()}<CoverLetterStep />{/snippet}
{#snippet review()}<ReviewStep />{/snippet}
</PathShell>
Svelte 5 snippets replace JSX. The workflow import is identical to every other framework.
import { PathShell } from "@daltonr/pathwrite-solid";
import { createApplicationPath } from "my-app-workflows"; // ← shared package
import { apiServices } from "./services";
export default function JobApplication() {
return (
<PathShell
path={createApplicationPath(apiServices)}
initialData={{ roleId: "", yearsExperience: "" }}
onComplete={handleSubmit}
steps={{
role: () => <RoleStep />,
experience: () => <ExperienceStep />,
eligibility: () => <EligibilityStep />,
"cover-letter": () => <CoverLetterStep />,
review: () => <ReviewStep />,
}}
/>
);
}
Same workflow package import. Steps take render functions — Solid's fine-grained reactivity handles updates without re-rendering the whole component.
// The only difference from the React web version is this import.
import { PathShell } from "@daltonr/pathwrite-react-native";
import { createApplicationPath } from "my-app-workflows"; // ← same shared package
import { apiServices } from "./services";
export default function JobApplication() {
return (
<PathShell
path={createApplicationPath(apiServices)}
initialData={{ roleId: "", yearsExperience: "" }}
onComplete={handleSubmit}
steps={{
role: <RoleStep />,
experience: <ExperienceStep />,
eligibility: <EligibilityStep />,
"cover-letter": <CoverLetterStep />,
review: <ReviewStep />,
}}
/>
);
}
Same workflow, same tests, iOS and Android. See it running live below.
Every demo runs the same workflow package across all adapters.
Guards, per-field validation, lifecycle hooks, and a review step.
Steps dynamically skipped based on user choices. Stepper updates automatically.
Nested sub-wizards that auto-resume the parent on completion.
Contact form with field validation, error timing, and form-mode layout.
Steps that branch at runtime — show different sub-steps based on user data.
LocalStorage persistence with auto-save, multi-session management, and progress restore.
Installation, core concepts, and your first path — step by step.
12-chapter narrative guide from engine model through persistence, testing, and beyond.
Full PathEngine, PathSnapshot, PathStore, and utility function reference.
Every CSS custom property for theming PathShell with no selector battles.
Install Pathwrite and ship your first flow today.
npm install @daltonr/pathwrite-core
@daltonr/pathwrite-react
@daltonr/pathwrite-vue
@daltonr/pathwrite-angular
@daltonr/pathwrite-svelte
@daltonr/pathwrite-solid
@daltonr/pathwrite-react-native
@daltonr/pathwrite-store