Lifecycle Hooks
ink-stepper provides lifecycle hooks that allow you to execute logic when steps are entered or exited. This is useful for analytics, saving data, or performing cleanup.
Entering a Step
The onEnterStep callback is triggered whenever the active step changes. It receives the index of the new step.
<Stepper
onEnterStep={(stepIndex) => {
console.log(`Navigated to step ${stepIndex}`);
analytics.trackView(`step_${stepIndex}`);
}}
>
{/* ... */}
</Stepper>Exiting a Step
The onExitStep callback is triggered before leaving the current step. It can be used to validate data, save state, or prevent navigation.
<Stepper
onExitStep={async (stepIndex) => {
console.log(`Leaving step ${stepIndex}`);
// Perform cleanup or save - no return value needed
await saveData(stepIndex);
}}
>
{/* ... */}
</Stepper>The signature is (step: number) => void | boolean | Promise<void | boolean>. Only an explicit false cancels navigation, so a handler that just performs a side effect can return nothing.
Preventing Navigation
If onExitStep returns false (or a Promise that resolves to false), the navigation is cancelled, and the user remains on the current step. This applies to goNext(), goBack() and goTo() alike.
<Stepper
onExitStep={(step) => {
if (step === 0 && !formIsValid) {
console.log('Cannot leave step 0 yet!');
return false;
}
return true;
}}
>
{/* ... */}
</Stepper>Callback Order
Every user-initiated navigation runs the same sequence:
onExitStep(from) → onStepChange(to) → onEnterStep(to)goNext() additionally resolves canProceed before any of it; if the check fails, nothing fires. Reaching the end of the wizard calls onComplete instead of the change/enter pair, and going back from the first step calls onCancel.
onExitStep runs before those terminal callbacks too:
onExitStep(last) → onComplete() // advancing past the last step
onExitStep(0) → onCancel() // going back from the first stepSo returning false from onExitStep blocks completion and cancellation exactly the same way it blocks a step change — the wizard stays where it is and neither onComplete nor onCancel fires.
Programmatic Jumps with goTo
goTo(index) fires the same full lifecycle as goNext/goBack, so onExitStep can cancel a jump by returning false. Two things make it different:
- It skips
canProceedon the current step —goTois a raw jump, not a validated advance. Use it for "back to summary" style navigation, not to bypass validation on the way forward. - The index is clamped to the valid range, and a jump to the current index is a no-op (no callbacks fire).
import { Text, useInput } from 'ink';
function EditFirstHint({ onEdit }: { onEdit: () => void }) {
useInput((input) => {
if (input === '1') onEdit();
});
return <Text dimColor>Press "1" to edit the first step.</Text>;
}
<Step name="Review">
{({ goTo }) => <EditFirstHint onEdit={() => goTo(0)} />}
</Step>The Review step of examples/wizard.tsx is exactly this: press 1 to goTo(0) and watch the lifecycle log.
Like goNext and goBack, goTo does nothing while async validation is in flight or while navigation is disabled via useStepperInput.
Errors in Callbacks
If onExitStep throws or returns a rejecting Promise, navigation is blocked and the error is passed to the onError prop (or logged via console.error when that prop is omitted). See Validation.
Silent Index Repairs
Conditional steps can appear and disappear. When that happens, the Stepper keeps the user on the same step by re-pointing the internal index at the step they were already on. If that step itself was removed, the index stays put — whichever step slides into that position becomes active — and only clamps to the last remaining step when the removed step was the last one.
These repairs are not navigation: onExitStep, onStepChange and onEnterStep do not fire for them. Only user-initiated goNext()/goBack()/goTo() calls and keyboard navigation trigger the lifecycle.