React Integration Guide
This guide covers how to use the useWizard hook to integrate wizards into your React application.
Installation
npm install @gooonzick/wizard-core @gooonzick/wizard-reactBasic Usage
The useWizard() hook connects a wizard definition to React state and provides everything you need to build a wizard UI.
import { useWizard } from "@gooonzick/wizard-react";
import { createLinearWizard } from "@gooonzick/wizard-core";
type SignupData = {
name: string;
email: string;
};
const wizard = createLinearWizard<SignupData>({
id: "signup",
steps: [
{
id: "personal",
title: "Personal Info",
validate: (data) => ({
valid: Boolean(data.name),
errors: data.name ? undefined : { name: "Required" },
}),
},
{
id: "contact",
title: "Contact Info",
validate: (data) => ({
valid: Boolean(data.email),
errors: data.email ? undefined : { email: "Required" },
}),
},
],
onComplete: (data) => {
console.log("Completed!", data);
},
});
export function SignupForm() {
const { state, navigation, actions, validation, loading } = useWizard({
definition: wizardDef,
initialData: { name: "", email: "" },
onComplete: (data) => {
console.log("Form completed:", data);
},
});
return (
<div>
<h2>{state.currentStep.meta?.title}</h2>
{state.currentStepId === "personal" && (
<input
value={state.data.name}
onChange={(e) => actions.updateField("name", e.target.value)}
placeholder="Name"
/>
)}
{state.currentStepId === "contact" && (
<input
value={state.data.email}
onChange={(e) => actions.updateField("email", e.target.value)}
placeholder="Email"
/>
)}
{validation.validationErrors && (
<div className="errors">
{Object.entries(validation.validationErrors).map(([field, error]) => (
<p key={field}>{error}</p>
))}
</div>
)}
<button
onClick={() => navigation.goPrevious()}
disabled={!navigation.canGoPrevious || loading.isNavigating}
>
Previous
</button>
<button
onClick={() => navigation.goNext()}
disabled={loading.isNavigating}
>
{/* On the last step goNext() completes the wizard; canGoNext is false there. */}
{navigation.isLastStep ? "Complete" : "Next"}
</button>
</div>
);
}useWizard Hook API
Options
interface UseWizardOptions<T> {
definition: WizardDefinition<T>;
initialData: T;
context?: WizardContext;
onStateChange?: (state: WizardState<T>) => void;
onStepEnter?: (stepId: string, data: T) => void;
onStepLeave?: (stepId: string, data: T) => void;
onComplete?: (data: T) => void;
onCancel?: (data: T) => void | Promise<void>;
onReset?: () => void;
onError?: (error: Error) => void;
onDataChange?: (prevData: T, nextData: T, changedFields: (keyof T)[]) => void;
/** Reference-stable — read once at machine creation. */
plugins?: WizardPlugin<T>[];
}Return Value
The hook returns an organized object with state grouped into five logical slices:
const { state, validation, navigation, loading, actions } = useWizard({ ... });State Slice
state.currentStepId; // Current step ID
state.currentStep; // Current step definition
state.data; // Current wizard data
state.isCompleted; // Has wizard completed?
state.stepStatuses; // Record<StepId, StepStatus> — status of every step
state.progress; // WizardProgress — computed totals, index, percentageValidation Slice
validation.isValid; // Is current step valid?
validation.validationErrors; // Field-level errorsNavigation Slice
// State
navigation.canGoNext; // Can move to next step?
navigation.canGoPrevious; // Can move to previous step?
navigation.canGoBack; // Can go back via history stack?
navigation.isFirstStep; // Is on first step?
navigation.isLastStep; // Is on last step?
navigation.visitedSteps; // Array of visited step IDs
navigation.availableSteps; // Array of currently enabled steps
navigation.stepHistory; // Navigation history stack
// Actions
navigation.goNext(); // Go to next step
navigation.goPrevious(); // Go to previous step (pops from history)
navigation.goBack(steps); // Go back N steps (deprecated, use goPrevious)
navigation.goTo(stepId); // Jump to specific step (validates first)Loading Slice
loading.isValidating; // Validation in progress?
loading.isSubmitting; // Submission in progress?
loading.isNavigating; // Navigation in progress?
loading.isLoadingStep; // A lazy step's implementation is loading? (see Lazy Steps)Actions Slice
// Update data
actions.updateData((data) => ({ ...data, name: "John" }));
actions.setData(completeData);
actions.updateField("name", "John");React to data changes by passing onDataChange to useWizard. It fires after any updateField / updateData / setData that changes a top-level field (after onStateChange; not on reset/restore/navigation):
const { actions } = useWizard({
definition,
initialData,
onDataChange: (prev, next, changedFields) => {
if (changedFields.includes("plan")) {
actions.updateField("price", PRICES[next.plan]);
}
},
});// Validation
actions.validate(); // Manually validate (result goes to validation slice)
actions.validateAll(); // Dry-run all enabled steps → ValidationSummary
actions.validateAll({ updateStatuses: true }); // Also mark invalid steps as "error"
actions.canSubmit(); // Can current step be submitted?
actions.submit(); // Submit current step
// Reset / Cancel
actions.reset(); // Rewind to initial step + initial data, fires onReset
actions.reset(newData); // Rewind with replacement data
actions.cancel(); // Awaits definition.onCancel + onCancel event, then resets
// Persistence
actions.serialize(); // JSON-safe runtime snapshot
actions.restore(savedState); // Re-apply snapshot (throws WizardRestoreError if incompatible)
// (Prefer `createPersistencePlugin` for automatic save/restore — see "Persisting Progress".)
// Lazy steps
actions.preloadStep("documents"); // Prefetch a lazy step's implementation (never rejects)Steps declared with load / .lazy() load their validate and lifecycle hooks on first use; show a spinner with loading.isLoadingStep. actions.preloadStep does not set the flag and never rejects (failures are reported by the navigation that needs the step), so it is safe to fire and forget from hover/focus handlers; machine.preloadStep() in core still rejects. See Lazy Steps.
Reset & Cancel
Use actions.reset() to rewind the wizard to its initial step and original data — for example after an error, or to let the user start over.
<button onClick={() => actions.reset()}>Start over</button>Use actions.cancel() to abandon the wizard. It awaits the optional definition.onCancel handler (where you can run server-side cleanup, e.g. delete a draft) and then resets.
const handleCancel = async () => {
if (!confirm("Discard your progress?")) return;
await actions.cancel();
};Observe both via the onReset and onCancel event hooks:
useWizard({
definition,
initialData,
onCancel: (data) =>
analytics.track("wizard_cancelled", { email: data.email }),
onReset: () => analytics.track("wizard_reset"),
});Declarative server cleanup via the wizard definition:
createWizard<SignupData>("signup")
// …steps…
.onCancel(async (data) => {
await api.deleteDraft(data.draftId);
})
.build();Common Patterns
Conditional Rendering
export function WizardForm() {
const { state, actions } = useWizard({ definition, initialData });
return (
<div>
<h2>{state.currentStep.meta?.title}</h2>
{state.currentStepId === "personal" && (
<PersonalForm data={state.data} onUpdate={actions.updateField} />
)}
{state.currentStepId === "address" && (
<AddressForm data={state.data} onUpdate={actions.updateField} />
)}
{state.currentStepId === "review" && <ReviewForm data={state.data} />}
</div>
);
}Form Library Integration (React Hook Form)
import { useForm } from "react-hook-form";
import { useWizard } from "@gooonzick/wizard-react";
export function FormWizard() {
const { register, handleSubmit, watch } = useForm();
const { state, navigation, actions, validation } = useWizard({
definition,
initialData,
});
const formData = watch();
const handleStepSubmit = async () => {
// Validate current step
await actions.validate();
if (validation.isValid) {
await navigation.goNext();
}
};
return (
<form onSubmit={handleSubmit(handleStepSubmit)}>
{state.currentStepId === "personal" && <input {...register("name")} />}
<button type="submit">
{navigation.isLastStep ? "Complete" : "Next"}
</button>
</form>
);
}Custom Context with API Client
interface ApiContext extends WizardContext {
api: ApiClient;
onError?: (error: Error) => void;
}
const apiContext: ApiContext = {
api: new ApiClient({
baseURL: "https://api.example.com",
}),
};
export function SignupWizard() {
const { state, navigation, actions } = useWizard({
definition: signupDefinition,
initialData: { email: "", plan: "basic" },
context: apiContext,
onError: (error) => {
console.error("Wizard error:", error);
},
});
// The definition can now use apiContext in validators:
// validate: async (data, ctx) => {
// const available = await (ctx as ApiContext).api.checkEmail(data.email);
// return { valid: available };
// }
}Persisting Progress
The recommended path is the built-in persistence plugin — it restores on init, debounces auto-save, and clears the record on completion / reset. See the Plugins guide for the full contract.
import { useMemo } from "react";
import {
createPersistencePlugin,
localStorageAdapter,
} from "@gooonzick/wizard-core";
export function PersistentWizard() {
// Create the plugin ONCE: `plugins` is read a single time, at machine creation.
const plugins = useMemo(
() => [
createPersistencePlugin<SignupData>({
adapter: localStorageAdapter<SignupData>("wizard:signup"),
debounceMs: 300,
beforeSave: (state) => ({
...state,
data: { ...state.data, password: "" }, // never persist secrets
}),
}),
],
[],
);
// With the synchronous localStorage adapter the snapshot is applied inside
// `onInit`, i.e. before the first render — no restore flash, no extra effect.
const { state, actions, navigation } = useWizard({
definition,
initialData,
plugins,
});
return <WizardForm state={state} actions={actions} navigation={navigation} />;
}Keep actions.serialize() / actions.restore() for the manual case (a custom backend you drive yourself, or restoring on demand rather than on mount):
export function PersistentWizard() {
const { state, actions, navigation } = useWizard({ definition, initialData });
// Save the full runtime snapshot on every change.
useEffect(() => {
localStorage.setItem("wizard-state", JSON.stringify(actions.serialize()));
}, [state, actions]);
// Restore once on mount; clear storage if the saved draft is incompatible.
useEffect(() => {
const saved = localStorage.getItem("wizard-state");
if (!saved) return;
try {
actions.restore(JSON.parse(saved));
} catch {
localStorage.removeItem("wizard-state");
}
}, [actions]);
return (
<WizardForm
state={state}
actions={actions}
navigation={navigation}
/>
);
}See examples/react-examples for working demos:
src/wizard-example.tsx— basic flow +validateAllsrc/provider-example.tsx—WizardProvider+ granular hookssrc/state-persistence-example.tsx— manual serialize / restoresrc/persistence-plugin-example.tsx—createPersistencePlugin+localStorageAdaptersrc/plugins-example.tsx— logging + custom pluginssrc/analytics-example.tsx—createAnalyticsPlugin+ livegetReport()src/data-change-example.tsx—onDataChangecascadessrc/reset-cancel-example.tsx,src/history-example.tsx
Handling Errors
export function SafeWizard() {
const [error, setError] = useState<string | null>(null);
const wizard = useWizard({
definition,
initialData: {
/* ... */
},
onError: (error) => {
setError(error.message);
},
});
if (error) {
return (
<div className="error">
<p>{error}</p>
<button onClick={() => setError(null)}>Dismiss</button>
</div>
);
}
return (
<WizardForm
state={wizard.state}
actions={wizard.actions}
navigation={wizard.navigation}
/>
);
}Tracking Progress
The state.progress slice exposes a precomputed WizardProgress snapshot, so you don't need to derive totals or percentages by hand.
export function ProgressWizard() {
const { state } = useWizard({ definition, initialData });
const { progress } = state;
return (
<div>
<div
className="progress-bar"
style={{ width: `${progress.percentage}%` }}
/>
<p>
Step {progress.currentStepIndex + 1} of {progress.enabledSteps} ·{" "}
{progress.completedSteps} completed ({progress.percentage}%)
</p>
{state.currentStepId === "review" && (
<div className="review">
{progress.enabledStepIds.map((stepId) => (
<div key={stepId}>
<h4>
{stepId} — {state.stepStatuses[stepId]}
</h4>
</div>
))}
</div>
)}
</div>
);
}Loading and Busy States
export function StepActions() {
const { navigation, loading } = useWizard({ definition, initialData });
return (
<div>
<button
onClick={() => navigation.goPrevious()}
disabled={!navigation.canGoPrevious || loading.isNavigating}
>
{loading.isNavigating ? "Loading..." : "Previous"}
</button>
<button
onClick={() => navigation.goNext()}
disabled={loading.isNavigating}
>
{loading.isValidating
? "Validating..."
: navigation.isLastStep
? "Finish"
: "Next"}
</button>
{loading.isSubmitting && <p>Submitting...</p>}
</div>
);
}Multi-step with Tabs
export function TabWizard() {
const { state, navigation } = useWizard({ definition, initialData });
return (
<div>
<div className="tabs">
{navigation.availableSteps.map((stepId) => {
const step = definition.steps[stepId];
const isActive = stepId === state.currentStepId;
const isVisited = navigation.visitedSteps.includes(stepId);
return (
<button
key={stepId}
onClick={() => isVisited && navigation.goTo(stepId)}
className={`tab ${isActive ? "active" : ""}`}
disabled={!isVisited}
>
{step.meta?.title}
</button>
);
})}
</div>
<div className="content">{/* Render current step */}</div>
</div>
);
}Organized Hook Return Value
The useWizard() hook returns an organized object with state grouped by concern:
const { state, validation, navigation, loading, actions } = useWizard({
definition,
initialData,
});
// State slice - current step and data
state.data; // Current form data
state.currentStepId; // Current step
state.currentStep; // Current step definition
state.isCompleted; // Is wizard completed?
// Validation slice
validation.isValid; // Is current step valid?
validation.validationErrors; // Field validation errors
// Navigation slice - state and methods
navigation.canGoNext; // Can move forward?
navigation.canGoPrevious; // Can move backward?
navigation.canGoBack; // Can go back via history stack?
navigation.isFirstStep; // Is on first step?
navigation.isLastStep; // Is on last step?
navigation.visitedSteps; // Visited step IDs
navigation.availableSteps; // Enabled step IDs
navigation.stepHistory; // Navigation history stack
navigation.goNext(); // Navigate to next
navigation.goPrevious(); // Navigate to previous (pops history)
navigation.goBack(n); // Go back n steps (deprecated)
navigation.goTo(id); // Jump to specific step (validates first)
// Loading slice
loading.isValidating; // Validation in progress?
loading.isSubmitting; // Submission in progress?
loading.isNavigating; // Navigation in progress?
// Actions slice
actions.updateField("name", "John"); // Update form field
actions.updateData((d) => ({ ...d, name: "John" })); // Update with function
actions.setData(newData); // Replace all data
actions.validate(); // Trigger validation
actions.canSubmit(); // Check if can submit
actions.submit(); // Submit current step
actions.reset(); // Reset to initial dataGranular Hooks (Optional Provider)
For fine-grained subscriptions that prevent unnecessary re-renders, wrap your component tree with WizardProvider:
import {
WizardProvider,
useWizardData,
useWizardNavigation,
useWizardValidation,
useWizardLoading,
useWizardActions,
} from "@gooonzick/wizard-react";
function App() {
return (
<WizardProvider definition={myWizard} initialData={initialData}>
<MyWizardForm />
</WizardProvider>
);
}
function MyWizardForm() {
// Only subscribes to data changes
const { data, currentStepId } = useWizardData();
// Only subscribes to navigation changes
const { isLastStep, goNext } = useWizardNavigation();
// Only subscribes to validation changes
const { isValid, validationErrors } = useWizardValidation();
// Only subscribes to loading flags
const { isNavigating } = useWizardLoading();
// Actions don't cause re-renders
const { updateField } = useWizardActions();
return (
<div>
{currentStepId === "personal" && (
<input
value={data.name}
onChange={(e) => updateField("name", e.target.value)}
/>
)}
{!isValid && validationErrors && (
<p className="error">{Object.values(validationErrors).join(", ")}</p>
)}
{/* On the last step goNext() completes the wizard; canGoNext is false there. */}
<button onClick={() => goNext()} disabled={isNavigating}>
{isLastStep ? "Finish" : "Next"}
</button>
</div>
);
}Available Granular Hooks
| Hook | Returns | Use Case |
|---|---|---|
useWizardData<T>() | Current step, data, isCompleted | Form inputs, step content |
useWizardNavigation() | canGoNext, goNext, goBack, etc. | Navigation buttons |
useWizardValidation() | isValid, validationErrors | Error display |
useWizardLoading() | isValidating, isSubmitting, isNavigating, isLoadingStep | Loading indicators |
useWizardActions<T>() | updateField, validateAll, submit, reset, cancel, serialize, restore | Form handlers |
When to Use Granular Hooks
Use the provider + granular hooks pattern when:
- Your wizard has many components that only need specific slices of state
- You're experiencing performance issues from unnecessary re-renders
- You want to optimize rendering in large forms
Use the standalone useWizard() pattern when:
- You have a simple wizard with few components
- Performance is not a concern
- You prefer a simpler API
Migration Guide
The useWizard() hook now returns an organized object with nested slices:
// New API structure
const { state, validation, navigation, loading, actions } = useWizard({
definition,
initialData,
});
// Access state
const data = state.data;
const currentStepId = state.currentStepId;
// Access navigation
const canGoNext = navigation.canGoNext;
await navigation.goNext();
// Access actions
actions.updateField("name", "John");To adopt granular hooks for performance optimization:
// Step 1: Wrap with provider
<WizardProvider definition={...} initialData={...}>
<MyComponent />
</WizardProvider>
// Step 2: Use granular hooks in nested components
const { data, currentStepId } = useWizardData();
const { canGoNext, goNext } = useWizardNavigation();
const { updateField } = useWizardActions();Best Practices
1. Separate Concerns
Keep the wizard logic separate from your UI component:
// ❌ Don't: Logic mixed with UI
function MyWizard() {
const { actions } = useWizard({ definition, initialData });
return (
<input onChange={(e) => actions.updateField("name", e.target.value)} />
);
}
// ✅ Do: Use sub-components
function MyWizard() {
const wizard = useWizard({ definition, initialData });
return <PersonalStep state={wizard.state} actions={wizard.actions} />;
}
function PersonalStep({
state,
actions,
}: {
state: UseWizardState<MyData>;
actions: UseWizardActions<MyData>;
}) {
return (
<input onChange={(e) => actions.updateField("name", e.target.value)} />
);
}2. Handle Async Operations
Always await navigation and validation:
// ❌ Don't: Fire and forget
const handleNext = () => {
navigation.goNext(); // Don't await
};
// ✅ Do: Await navigation
const handleNext = async () => {
await actions.validate();
if (validation.isValid) {
await navigation.goNext();
}
};3. Memoize Definition
Create the definition once, outside the component:
// ❌ Don't: Recreate definition on every render
function MyWizard() {
const definition = createWizard(...).build();
const wizard = useWizard({ definition, ... });
}
// ✅ Do: Create once
const definition = createWizard(...).build();
function MyWizard() {
const wizard = useWizard({ definition, ... });
}4. Use TypeScript
Leverage the type system:
// ✅ Do: Type your data and let TypeScript help
type MyData = {
name: string;
email: string;
};
const wizard = useWizard<MyData>({
definition,
initialData: { name: "", email: "" },
});
// TypeScript knows wizard.data.name exists5. Validate Before Navigation
Check validity before moving to the next step:
// ✅ Do: Validate first
const handleNext = async () => {
await actions.validate();
if (validation.isValid) {
await navigation.goNext();
}
};Debugging
Log State Changes
useWizard({
definition,
initialData,
onStateChange: (state) => {
console.log("State changed:", state);
},
});Inspect Hook Return Value
const wizard = useWizard({ definition, initialData });
console.log(wizard); // See all slices: state, validation, navigation, loading, actionsUse React DevTools
Install React DevTools browser extension to inspect hook state in real-time.
Enable Debug Mode
const wizard = useWizard({
definition,
initialData,
context: { debug: true },
});Troubleshooting
Wizard won't move to next step
Check:
- Is validation passing? (
validation.isValid) - Are all required fields filled?
- Is there an
onErrorhandler showing errors?
Data not updating
Check:
- Are you using
actions.updateField()correctly? - Is the field name correct?
- Use
console.log(state.data)to inspect
Context not available
Check:
- Did you pass
contexttouseWizard()? - Are you casting correctly?
ctx as MyContext - Is the context value set?
Performance Optimization
Memoize Sub-Components
const PersonalStep = React.memo(({ data, onUpdate }: Props) => {
return <form>{/* ... */}</form>;
});Use useCallback for Handlers
const handleNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
actions.updateField("name", e.target.value);
},
[actions],
);Avoid Inline Object Creation
// ❌ Don't: Creates new object every render
const wizard = useWizard({
definition,
initialData: { name: "", email: "" },
});
// ✅ Do: Move outside component
const initialData = { name: "", email: "" };
const wizard = useWizard({
definition,
initialData,
});