use Graft Step

A position in a sequence, clamped between a minimum and a maximum, with the way back.

API

Options

proptypedefault
defaultValue?number0the position it starts on
min?number-Infinitylower bound
max?numberInfinityupper bound

Returns

proptypedefault
step?numberthe current position
min?numberthe lower bound, as given
max?numberthe upper bound, as given
history?readonly number[]every position visited, in order
setStep?(step: number) => voidjumps, clamped
onBack?() => voidone back, clamped
onNext?() => voidone forward, clamped
id?stringstable id

Usage

A position, with bounds

tsx
// -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>
    </>
  );
};
previewclamped to 0 and 5 — the bounds come back out, so the ends disable themselves
step 0
history: 0

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.

tsx
// 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.