use Graft Step
A position in a sequence, clamped between a minimum and a maximum, with the way back.
API
Options
| prop | type | default | |
|---|---|---|---|
defaultValue? | number | 0 | the position it starts on |
min? | number | -Infinity | lower bound |
max? | number | Infinity | upper bound |
Returns
| prop | type | default | |
|---|---|---|---|
step? | number | — | the current position |
min? | number | — | the lower bound, as given |
max? | number | — | the upper bound, as given |
history? | readonly number[] | — | every position visited, in order |
setStep? | (step: number) => void | — | jumps, clamped |
onBack? | () => void | — | one back, clamped |
onNext? | () => void | — | one forward, clamped |
id? | string | — | stable id |
Usage
A position, with bounds
// -constants/graft.ts
export const GraftSteps = createGraft({ name: "GraftSteps", graft: useGraftStep });
// -components/wizard.tsx
export const Wizard = () => (
<GraftSteps.Provider min={0} max={5}>
<StepControls />
</GraftSteps.Provider>
);
// -components/step_controls.tsx
export const StepControls = () => {
const { max, min, onBack, onNext, step } = GraftSteps.use();
return (
<>
<Button disabled={step === min} onClick={onBack}>back</Button>
<Button disabled={step === max} onClick={onNext}>next</Button>
<Chip>{`step ${step}`}</Chip>
</>
);
};Details
What it is good for
Anything that shows one thing at a time out of several. A wizard, where onBack and onNext are the whole navigation and the bounds are the first and last page. A carousel, where the same two operations move between slides. An onboarding flow that has to be resumable. A form split across sections, where the position decides which one is on screen.
Tabs are the same thing with the moves made explicit: instead of back and next, each button jumps to its own position with setStep. Nothing here draws headers or assumes a direction, so a segmented control, a strip of tabs and a stack of panels are one hook with different buttons on top.
// tabs: the position is which panel is showing
const { setStep, step } = GraftPanels.use();
<Button active={step === 0} onClick={() => setStep(0)}>customer</Button>
<Button active={step === 1} onClick={() => setStep(1)}>status</Button>
<Swap.Boolean swapOn={step === 1} components={[<CustomerPanel />, <StatusPanel />]} />The reason it is not called useGraftTab: a tab strip is the degenerate case of a sequence, not the other way round. Naming it after the smallest use would have made the other four look like misuse.
Clamped, not guarded
Every way in goes through the same clamp, so a position outside the range cannot be reached — not by onNext at the end, not by setStep with a number out of nowhere. Nothing throws and nothing warns: the value is simply pulled back inside.
That is what lets a caller stay dumb. A next button does not need to know how many steps there are, and a deep link arriving with a bad index lands somewhere valid instead of on a blank screen.
The bounds come back out as min and max for the one thing clamping cannot do on its own: telling the ends apart from the middle. A button at the end of the range stays clickable and does nothing, which reads as broken — disabling it needs the bound, so the hook hands it back rather than making you write it twice.
The history is the interesting part
history records every position visited, in order, and that is more than a way back. It tells you which steps were skipped, so a summary can list only what was actually filled in; it feeds a breadcrumb without a second piece of state; and it answers whether someone has already been through a part of the flow, which is usually the question behind "have they seen this yet".
It lives in a ref, so recording a move costs nothing and nobody re-renders because of it. Read it when you need it — it is a log, not a subscription.