All files / hooks/use-step/src useStep.ts

100% Statements 39/39
100% Branches 13/13
100% Functions 15/15
100% Lines 38/38

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167          158x                   39x 39x               61x                                                                                                                 61x 61x       61x 61x       61x 61x 26x     61x 26x       61x             61x 32x     61x 13x 13x 13x 13x       61x 2x 2x 2x       61x 13x 13x 13x 13x     13x       61x 2x     61x 61x   61x 48x                                   61x    
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { StepUpdater, UseStepControls, UseStepReturn } from "./types";
 
/** Clamp `value` into the inclusive range `[min, max]`. */
function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(value, max));
}
 
/**
 * Normalize a candidate step target to a valid in-range index. The value is
 * floored and clamped to `[0, lastIndex]`; non-finite results (e.g. `NaN`,
 * `Infinity` that floors to a non-finite value) fall back to `fallback`,
 * guaranteeing `currentStep` is never corrupted by a bad target.
 */
function sanitizeStep(next: number, lastIndex: number, fallback: number): number {
  const floored = Math.floor(next);
  return Number.isFinite(floored) ? clamp(floored, 0, lastIndex) : fallback;
}
 
/**
 * Normalize a raw `count` argument to a whole number of steps (at least 1).
 * Non-finite or sub-1 values fall back to a single step.
 */
function resolveTotalSteps(count: number): number {
  return Number.isFinite(count) ? Math.max(1, Math.floor(count)) : 1;
}
 
/**
 * A React hook for managing navigation through a fixed number of steps —
 * wizards, multi-step forms, onboarding flows, carousels, and the like.
 *
 * Returns a tuple of the current **0-based** step index and a stable controls
 * object. All navigation is range-checked: you can never move past the first or
 * last step, and `setStep` clamps out-of-range targets. `canGoToNextStep` /
 * `canGoToPrevStep` are ready-made flags for disabling Prev/Next buttons.
 *
 * Features:
 * - 0-based indexing with automatic range validation (clamping)
 * - `goToNextStep` / `goToPrevStep` with `canGoToNextStep` / `canGoToPrevStep` flags
 * - `setStep` accepts an index or an updater function (like `useState`)
 * - `reset` back to the initial step
 * - Stable control identities — safe as effect dependencies
 * - Resilient to a changing `count`: the current step is kept within range
 * - No-op skipping: moving past an edge doesn't trigger a re-render
 *
 * @param count - Total number of steps. Valid indices are `0 .. count - 1`
 *   (e.g. `useStep(4)` has steps `0`, `1`, `2`, `3`). Values below 1 or
 *   non-finite are treated as a single step.
 * @param initialStep - The starting step index (floored and clamped). Defaults to `0`.
 * @returns `[currentStep, { goToNextStep, goToPrevStep, canGoToNextStep, canGoToPrevStep, setStep, reset }]`
 *
 * @example
 * ```tsx
 * function Wizard() {
 *   const [step, { goToNextStep, goToPrevStep, canGoToNextStep, canGoToPrevStep }] =
 *     useStep(4);
 *
 *   return (
 *     <div>
 *       {step === 0 && <InfoForm />}
 *       {step === 1 && <ConfirmForm />}
 *       {step === 2 && <PaymentForm />}
 *       {step === 3 && <CompleteMessage />}
 *
 *       <button onClick={goToPrevStep} disabled={!canGoToPrevStep}>Back</button>
 *       <button onClick={goToNextStep} disabled={!canGoToNextStep}>Next</button>
 *     </div>
 *   );
 * }
 * ```
 *
 * @example
 * ```tsx
 * // Jump around, start midway, reset
 * const [step, { setStep, reset }] = useStep(5, 2);
 * setStep(4);              // jump to the last step
 * setStep((s) => s - 2);   // updater form (clamped)
 * reset();                 // back to step 2
 * ```
 */
export function useStep(count: number, initialStep = 0): UseStepReturn {
  const totalSteps = resolveTotalSteps(count);
  const lastIndex = totalSteps - 1;
 
  // Mirror the latest last-index so the stable callbacks always clamp against
  // the current bounds, even if `count` changes between renders.
  const lastIndexRef = useRef(lastIndex);
  lastIndexRef.current = lastIndex;
 
  // Resolve and remember the initial step once (for `reset`). A non-finite
  // `initialStep` (e.g. `NaN`) falls back to `0` rather than corrupting state.
  const initialRef = useRef<number | null>(null);
  if (initialRef.current === null) {
    initialRef.current = sanitizeStep(initialStep, lastIndex, 0);
  }
 
  const [rawStep, setRawStep] = useState<number>(
    () => initialRef.current as number
  );
 
  // Keep the exposed step within the current range (handles a shrinking count).
  const currentStep = clamp(rawStep, 0, lastIndex);
 
  // When `count` shrinks below the stored raw step, normalize the underlying
  // state down to the clamped index. `currentStep` already reflects the clamp
  // during render; this keeps `rawStep` in sync so a subsequent edge move is a
  // true no-op (no wasted render). The functional updater bails out when the
  // value is already in range, so this is idempotent and StrictMode-safe.
  useEffect(() => {
    setRawStep((prev) => clamp(prev, 0, lastIndex));
  }, [lastIndex]);
 
  const goToNextStep = useCallback(() => {
    setRawStep((prev) => {
      const li = lastIndexRef.current;
      const cur = clamp(prev, 0, li);
      return cur < li ? cur + 1 : cur;
    });
  }, []);
 
  const goToPrevStep = useCallback(() => {
    setRawStep((prev) => {
      const cur = clamp(prev, 0, lastIndexRef.current);
      return cur > 0 ? cur - 1 : cur;
    });
  }, []);
 
  const setStep = useCallback((step: StepUpdater) => {
    setRawStep((prev) => {
      const li = lastIndexRef.current;
      const cur = clamp(prev, 0, li);
      const next = typeof step === "function" ? step(cur) : step;
      // A non-finite target (e.g. `NaN`) keeps the current step instead of
      // corrupting `currentStep`.
      return sanitizeStep(next, li, cur);
    });
  }, []);
 
  const reset = useCallback(() => {
    setRawStep(clamp(initialRef.current as number, 0, lastIndexRef.current));
  }, []);
 
  const canGoToNextStep = currentStep < lastIndex;
  const canGoToPrevStep = currentStep > 0;
 
  const controls = useMemo<UseStepControls>(
    () => ({
      goToNextStep,
      goToPrevStep,
      canGoToNextStep,
      canGoToPrevStep,
      setStep,
      reset,
    }),
    [
      goToNextStep,
      goToPrevStep,
      canGoToNextStep,
      canGoToPrevStep,
      setStep,
      reset,
    ]
  );
 
  return [currentStep, controls] as const;
}