@daltonr/pathwrite-solid
v0.15.0
Published
SolidJS adapter for @daltonr/pathwrite-core — reactive usePath() composable and optional PathShell component.
Maintainers
Readme
@daltonr/pathwrite-solid
SolidJS adapter for Pathwrite — exposes path engine state as a reactive createSignal accessor that integrates natively with SolidJS's fine-grained reactivity model.
Installation
npm install @daltonr/pathwrite-core @daltonr/pathwrite-solidPeer dependencies: solid-js >= 1.8.0
tsconfig.json — required jsxImportSource
Solid JSX requires "jsxImportSource": "solid-js" in your tsconfig.json. Using the wrong value (a common mistake is "solid-js/h") produces a cryptic TypeScript error that does not mention Solid or JSX at all.
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js"
}
}If you see a type error about JSX elements being Element | null | undefined when they should be JSX.Element, check this value first.
Quick start
// SignupFlow.tsx
import { PathShell } from "@daltonr/pathwrite-solid";
import "@daltonr/pathwrite-solid/styles.css";
import type { PathDefinition, PathData } from "@daltonr/pathwrite-core";
export interface SignupData extends PathData {
name: string;
email: string;
}
const signupPath: PathDefinition<SignupData> = {
id: "signup",
steps: [
{ id: "details", title: "Your Details" },
{ id: "review", title: "Review" },
],
};
export function SignupFlow() {
return (
<PathShell
path={signupPath}
initialData={{ name: "", email: "" }}
onComplete={(data) => console.log("Done!", data)}
steps={{
details: (snap) => <DetailsStep />,
review: (snap) => <ReviewStep />,
}}
/>
);
}// DetailsStep.tsx
import { usePathContext } from "@daltonr/pathwrite-solid";
import type { SignupData } from "./SignupFlow";
export function DetailsStep() {
const { snapshot, setData } = usePathContext<SignupData>();
return (
<div>
<input
value={snapshot().data.name}
onInput={(e) => setData("name", e.currentTarget.value)}
placeholder="Name"
/>
<input
value={snapshot().data.email}
onInput={(e) => setData("email", e.currentTarget.value)}
placeholder="Email"
/>
</div>
);
}Step components call usePathContext() to access engine state. <PathShell> provides the context automatically via createContext / useContext.
Each step render function runs once, when its step becomes current, and the component it returns lives until the path moves to a different step. Engine events that leave the step unchanged (setData on every keystroke, validate(), guard and hook status changes) do not re-create it, so inputs keep their DOM node, focus and local state. Live state reaches the step reactively either through usePathContext().snapshot() or through the snapshot argument of the render function, whose properties read the current snapshot: (snap) => <DetailsStep snapshot={snap} /> with createMemo(() => props.snapshot.data) inside stays up to date.
usePath
usePath<TData>() creates an isolated path engine instance. The composable registers an onCleanup handler to unsubscribe from the engine when the reactive scope is disposed — no manual cleanup needed.
| Return value | Type | Description |
|---|---|---|
| snapshot | Accessor<PathSnapshot \| null> | Current snapshot. Call snapshot() to read. null when no path is active or when completionBehaviour: "dismiss" is used. With the default "stayOnFinal", the accessor returns a snapshot with status === "completed" after the path finishes. Tracked reactively when read inside JSX or effects. |
| start(definition, data?) | function | Start or re-start a path. |
| next() | function | Advance one step. Completes the path on the last step. |
| previous() | function | Go back one step. No-op on the first step of a top-level path. |
| cancel() | function | Cancel the active path or sub-path. |
| goToStep(stepId) | function | Jump to a step by ID, bypassing guards and shouldSkip. |
| goToStepChecked(stepId) | function | Jump to a step by ID, checking the relevant navigation guard first. |
| setData(key, value) | function | Update a single data field. Type-checked when TData is provided. |
| resetStep() | function | Restore the current step's data to what it was when the step was entered. Emits stateChanged with cause "resetStep"; no hooks run. |
| startSubPath(definition, data?, meta?) | function | Push a sub-path. meta is echoed back to onSubPathComplete / onSubPathCancel. |
| suspend() | function | Pause the path with intent to return. Emits suspended; all state and data are preserved. |
| retry() | function | Re-run the operation that set snapshot().error. Increments retryCount on repeated failure. No-op when there is no pending error. |
| restart() | function | Tear down the active path without firing hooks and restart the root path with the initialData from the original start(). Takes no arguments; rejects if nothing has been started. |
| validate() | function | Set snapshot().hasValidated without navigating. Used to trigger inline errors across all tabs in a nested shell. |
PathShell props
<PathShell> renders a progress indicator, step content area, validation messages, and navigation buttons. Step content is provided as a steps map whose keys match each step's id.
<PathShell
path={myPath}
steps={{
details: (snap) => <DetailsStep />,
review: (snap) => <ReviewStep />,
}}
/>| Prop | Type | Default | Description |
|---|---|---|---|
| path | PathDefinition | required | The path to run. |
| steps | Record<string, (snapshot: PathSnapshot) => JSX.Element> | — | Step render functions keyed by step ID (or formId for StepChoice steps). |
| initialData | PathData | {} | Initial data passed to engine.start(). |
| engine | PathEngine | — | An externally-managed engine. When provided, PathShell skips its own start(). May be provided after mount (e.g. once an async restoreOrStart() resolves): the shell adopts it, re-subscribing and re-seeding from the new engine. Set autoStart to false if the shell should not start its own path in the meantime. |
| autoStart | boolean | true | Start the path automatically on mount. Ignored when engine is provided. |
| validationDisplay | "summary" \| "inline" \| "both" | "summary" | Where fieldErrors are rendered. Use "inline" to suppress the summary and handle errors inside step components. |
| layout | "wizard" \| "form" \| "auto" \| "tabs" | "auto" | "wizard": Back on left, Cancel+Submit on right. "form": Cancel on left, Submit on right, no Back. "tabs": No progress header or footer — for tabbed interfaces. "auto" picks "form" for single-step paths. |
| progressLayout | "merged" \| "split" \| "rootOnly" \| "activeOnly" | "merged" | How the root and sub-path progress bars are arranged while a sub-path is active. |
| hideProgress | boolean | false | Hide the progress indicator. Also hidden automatically for single-step top-level paths. |
| hideFooter | boolean | false | Hide the footer entirely. The error panel is still shown on async failure. |
| hideCancel | boolean | false | Hide the Cancel button. |
| backLabel | string | "Previous" | Previous button label. |
| nextLabel | string | "Next" | Next button label. |
| completeLabel | string | "Complete" | Complete button label (last step). |
| loadingLabel | string | undefined | Label for the Next/Complete button while an async operation is in progress. When unset, the button keeps its label and shows a CSS spinner. |
| cancelLabel | string | "Cancel" | Cancel button label. |
| class | string | — | Extra CSS class on the root element. |
| validateWhen | boolean | false | When it becomes true, calls validate() on the engine. Bind to the outer shell's hasAttemptedNext for nested shells. |
| services | object \| null | null | Services object passed through context to all step components. |
| restoreKey | string | — | When set, the shell automatically saves its full state (data + active step) into the nearest outer PathShell's data under this key on every change, and restores from it on remount. No-op on a top-level shell. The stored value also carries the inner engine's serialized state, so a remount restores in place: no onEnter / onLeave re-run, attempted / visited state kept. |
| renderHeader | (snapshot) => JSX.Element | — | Replace the default progress header. A custom header is shown even for single-step paths, and hidden under hideProgress or layout="tabs". |
| renderFooter | (snapshot, actions) => JSX.Element | — | Replace the default navigation buttons. actions contains next, previous, cancel, goToStep, goToStepChecked, setData, restart, retry, suspend. |
| completionContent | (snapshot: PathSnapshot) => JSX.Element | — | Custom content rendered when snapshot().status === "completed" (completionBehaviour: "stayOnFinal"). Receives the completed snapshot. If omitted, a default "All done." panel is shown. |
| onComplete | (data: PathData) => void | — | Called when the path completes. |
| onCancel | (data: PathData) => void | — | Called when the path is cancelled. |
| onEvent | (event: PathEvent) => void | — | Called for every engine event. |
usePathContext
usePathContext<TData, TServices>() reads the engine instance provided by the nearest <PathShell> ancestor. It returns the same shape as usePath — snapshot, next, previous, cancel, setData, and all other action callbacks. The snapshot is the same Accessor<PathSnapshot | null> — call snapshot() to read the current value.
Pass TData for typed access to snapshot()?.data and setData; pass TServices to type the services field. Must be called inside a component that is a descendant of <PathShell>. Throws a clear error if called outside one.
import { usePathContext } from "@daltonr/pathwrite-solid";
function DetailsStep() {
const { snapshot, setData } = usePathContext<SignupData>();
return (
<input
value={snapshot()?.data.name ?? ""}
onInput={(e) => setData("name", e.currentTarget.value)}
/>
);
}Avoid repeated snapshot() calls — use createMemo
Each call to snapshot() is a separate signal read. In a step component with several fields, calling it inline six times creates six subscriptions and six potential re-renders per update.
Wrap it in a createMemo to read the signal once and share the result:
import { createMemo } from "solid-js";
import { usePathContext } from "@daltonr/pathwrite-solid";
function DetailsStep() {
const { snapshot, setData } = usePathContext<ApplicationData>();
const data = createMemo(() => snapshot()?.data as ApplicationData | undefined);
const errors = createMemo(() => snapshot()?.fieldErrors);
const attempted = createMemo(() => snapshot()?.hasAttemptedNext ?? false);
return (
<div>
<input
value={data()?.name ?? ""}
onInput={(e) => setData("name", e.currentTarget.value)}
/>
<Show when={attempted() && errors()?.name}>
<p class="error">{errors()?.name}</p>
</Show>
</div>
);
}createMemo caches the derived value and only recomputes when the underlying signal changes, so all reads within the component share one subscription.
Complete example
A two-step job-application form with fieldErrors validation.
// application-path.ts
import type { PathDefinition, PathData } from "@daltonr/pathwrite-solid";
export interface ApplicationData extends PathData {
firstName: string;
email: string;
coverNote: string;
}
export const applicationPath: PathDefinition<ApplicationData> = {
id: "job-application",
steps: [
{
id: "details",
title: "Your Details",
fieldErrors: ({ data }) => ({
firstName: (data.firstName ?? "").trim().length < 2
? "First name must be at least 2 characters."
: undefined,
email: !(data.email ?? "").includes("@")
? "A valid email address is required."
: undefined,
}),
},
{
id: "cover-note",
title: "Cover Note",
fieldErrors: ({ data }) => ({
coverNote: (data.coverNote ?? "").trim().length < 20
? "Cover note must be at least 20 characters."
: undefined,
}),
},
],
};// DetailsStep.tsx
import { createMemo } from "solid-js";
import { usePathContext } from "@daltonr/pathwrite-solid";
import type { ApplicationData } from "./application-path";
export function DetailsStep() {
const { snapshot, setData } = usePathContext<ApplicationData>();
const data = createMemo(() => snapshot()?.data as ApplicationData | undefined);
const errors = createMemo(() => snapshot()?.fieldErrors);
const attempted = createMemo(() => snapshot()?.hasAttemptedNext ?? false);
return (
<div>
<label>First name</label>
<input
value={data()?.firstName ?? ""}
onInput={(e) => setData("firstName", e.currentTarget.value)}
/>
<Show when={attempted() && errors()?.firstName}>
<p class="error">{errors()?.firstName}</p>
</Show>
</div>
);
}// CoverNoteStep.tsx
import { createMemo } from "solid-js";
import { usePathContext } from "@daltonr/pathwrite-solid";
import type { ApplicationData } from "./application-path";
export function CoverNoteStep() {
const { snapshot, setData } = usePathContext<ApplicationData>();
const data = createMemo(() => snapshot()?.data as ApplicationData | undefined);
const errors = createMemo(() => snapshot()?.fieldErrors);
const attempted = createMemo(() => snapshot()?.hasAttemptedNext ?? false);
return (
<div>
<label>Cover note</label>
<textarea
value={data()?.coverNote ?? ""}
onInput={(e) => setData("coverNote", e.currentTarget.value)}
rows="6"
placeholder="Tell us why you're a great fit..."
/>
<Show when={attempted() && errors()?.coverNote}>
<p class="error">{errors()?.coverNote}</p>
</Show>
</div>
);
}// JobApplicationFlow.tsx — host component
import { PathShell } from "@daltonr/pathwrite-solid";
import "@daltonr/pathwrite-solid/styles.css";
import { applicationPath } from "./application-path";
import { DetailsStep } from "./DetailsStep";
import { CoverNoteStep } from "./CoverNoteStep";
export function JobApplicationFlow() {
return (
<PathShell
path={applicationPath}
initialData={{ firstName: "", email: "", coverNote: "" }}
onComplete={(data) => console.log("Application submitted:", data)}
steps={{
"details": (snap) => <DetailsStep />,
"cover-note": (snap) => <CoverNoteStep />,
}}
/>
);
}What this demonstrates:
fieldErrorson each step with auto-derivedcanMoveNext.snapshot().hasAttemptedNextgates inline error display.usePathContext()inside step components — provided automatically by<PathShell>.- The
stepsmap keyed by step ID, including hyphenated IDs like"cover-note".
Styling
<PathShell> ships with no embedded styles. Import the optional stylesheet:
import "@daltonr/pathwrite-solid/styles.css";All visual values are CSS custom properties:
:root {
--pw-color-primary: #8b5cf6;
--pw-shell-radius: 12px;
}Resetting the path
Option 1 — Toggle mount (simplest):
function App() {
const [isActive, setIsActive] = createSignal(true);
return (
<Show when={isActive()} fallback={<button onClick={() => setIsActive(true)}>Try Again</button>}>
<PathShell
path={myPath}
onComplete={() => setIsActive(false)}
steps={{ ... }}
/>
</Show>
);
}Option 2 — restart() via usePath (in-place, no unmount):
function App() {
const { snapshot, start, restart, next, previous } = usePath();
onMount(() => start(myPath, {}));
return (
<Show when={snapshot()}>
<div>
{/* render step content */}
<button onClick={() => restart()}>Start Over</button>
</div>
</Show>
);
}Further reading
© 2026 Devjoy Ltd. MIT License.
