Stepperize v8Explore the changes

Performance

Compare v7 and v8 across local state, shared subscriptions, navigation, guards, resets, validation and bundle size, with reproducible measurements.

Performance

These measurements compare the v7.0.0 source at the pinned commit below with the v8.0.0 working-tree source snapshot. Both versions are rebuilt with identical settings and use the same installed React and React DOM. This is a source comparison, not a comparison of two npm tarballs.

Overall benchmark result

47.3%

Lower benchmark time index

18/21

Scenarios with lower median time

3

Scenarios with higher median time

v8 vs v7 · 2026-09-15 · Equal-weight geometric mean across all 21 median time ratios, including regressions. Chromium, React production. This is a benchmark index, not an estimate of your app’s latency. 0 ties.

The overall index includes every timing scenario below, with equal weight per scenario. It combines local state, shared subscriptions, navigation, guards, resets and schema validation. Bundle sizes and render counts are separate metrics.

For each scenario, divide the v8 median by the v7 median. Take the geometric mean of those ratios, then calculate 100 × (1 − geometric mean) to express the reduction in the time index. A negative reduction means a regression. Normalizing first prevents a long-running fixture from dominating just because its times are larger.

This is a summary of this particular suite: changing its mix of scenarios changes the index. Selector scenarios compare adopting v8 selectors with v7 full subscriptions. All regressions are included; small differences can be measurement noise. The result does not establish statistical significance or predict browser latency.

Geometric means are also used to summarize normalized benchmark ratios in SPEC's methodology. These are Stepperize's own tests, not SPEC measurements.

21 measured scenarios · 2026-09-15

React 19.3.0 production · Chromium 153.0.8010.12 · Apple M4 Max.

Median of 21 samples after 5 warmups for each version and scenario. IQR is the middle 50% of sample times in milliseconds. Lower is better; a positive change means more time or bytes in v8.

v8 wins · v8 loses · tie. These indicators compare the measured values; they do not establish statistical significance.

Download raw results

Read each scenario separately, including regressions. v8 is not universally faster or smaller. A reduction in consumer renders does not imply an equal reduction in application latency. These are Chromium measurements with production React, orchestrated by Vitest Browser Mode. They measure fixture execution and React commits, not complete application frame times. Previous measurements used an emulated DOM; the runtimes and scheduling differ, so those percentages are not directly comparable.

What v8 optimizes

  • Local updates: the single owner receives immutable snapshots through React state. Shared consumers continue to use useSyncExternalStore and selectors.
  • Controlled state: consumers share one cached view of the Provider's render options. Committing that view preserves its identity, avoiding a second full-state render for the same values.
  • Repeated work: instances reuse the definition's step map, and unchanged status/completion helpers are reused between snapshots.
  • Navigation: callback contexts are created only when a callback exists. Status maps and abort signals are created when read. Pending guards still settle when cancelled, including guards that never resolve.

These changes keep the existing public API. The tables below are measured results, including any remaining increases; they do not promise a reduction for every workload or every run. Small deltas near the sampling variation need repeated measurements before being treated as reliable wins or regressions.

Local state

Mount 100 independent local instances in one React root, or make 200 data writes to one local instance. Each renders a span containing its current id or stored data. The 100-step cases show the effect of a larger definition; definitions are created once outside the timer.

Local state: v7 and v8 measurements. Lower values mean less time, renders, or code.
Scenariov7.0.0v8.0.0v8 vs v7
Mount 100 local flows10 steps0.33 msIQR 0.290.45100 renders0.28 msIQR 0.260.31100 rendersv8 wins: -13.6% time
Mount 100 local flows · 100 steps100 steps0.46 msIQR 0.360.48100 renders0.28 msIQR 0.230.29100 rendersv8 wins: -39.1% time
200 local data writes10 steps0.49 msIQR 0.430.56200 renders0.56 msIQR 0.510.63200 rendersv8 loses: +15.5% time
200 local data writes · 100 steps100 steps0.60 msIQR 0.490.69200 renders0.56 msIQR 0.490.60200 rendersv8 wins: -6.7% time

Shared subscriptions

One provider supplies a driver and 20 or 100 reader components. The driver subscribes to the whole instance, exposes the latest API to the harness, and returns null. Its work is included in elapsed time, but its renders are excluded from the displayed reader counts.

  • Full-state readers: both versions subscribe to the entire instance and display its id. This is the control case without selectors.
  • Id readers: v7 uses useStepper().id; v8 uses useStepperContext((state) => state.id). Data changes while the id stays the same. This measures the benefit of adopting selectors, not an automatic improvement for every v8 consumer.
  • Data readers: each displays the data being updated. v8's selected value changes on every write, so readers must render.
  • Completion readers: each displays a completion flag that changes on every toggle. This also checks work that selectors cannot skip.

After each timed trial, the harness changes the selected value and verifies that every reader updates. A broken subscription cannot appear fast by never updating.

Shared subscriptions: v7 and v8 measurements. Lower values mean less time, renders, or code.
Scenariov7.0.0v8.0.0v8 vs v7
200 data writes · 20 full-state readers10 steps4.84 msIQR 4.675.124,000 renders2.36 msIQR 2.342.554,000 rendersv8 wins: -51.4% time
200 data writes · 20 id readers10 steps4.83 msIQR 4.365.014,000 renders0.60 msIQR 0.570.640 rendersv8 wins: -87.6% time
200 data writes · 100 id readers10 steps15.07 msIQR 14.4118.0320,000 renders1.34 msIQR 1.201.380 rendersv8 wins: -91.1% time
200 data writes · 20 data readers10 steps4.85 msIQR 4.725.214,000 renders3.11 msIQR 2.993.174,000 rendersv8 wins: -35.9% time
200 completion toggles · 20 completion readers10 steps4.92 msIQR 4.415.234,000 renders3.03 msIQR 2.943.124,000 rendersv8 wins: -38.4% time

Make 200 accepted transitions, returning to the initial step after every pair. The basic case alternates next() and prev(). The 100-step case alternates goTo() between the first and last ids. The controlled case uses a React state owner and 20 id readers. The payload case passes a distinct { data } value on every move and verifies both source drafts.

Every operation awaits its navigation result and one event-loop turn before starting the next. The measured time therefore includes Promise and scheduling overhead as well as the React commits.

Navigation: v7 and v8 measurements. Lower values mean less time, renders, or code.
Scenariov7.0.0v8.0.0v8 vs v7
200 next / prev moves10 steps2.61 msIQR 2.472.73200 renders2.38 msIQR 2.322.45200 rendersv8 wins: -9.0% time
200 first / last jumps · 100 steps100 steps4.58 msIQR 4.474.74200 renders2.34 msIQR 2.302.51200 rendersv8 wins: -48.9% time
200 controlled moves · 20 id readers10 steps7.50 msIQR 7.188.184,000 renders5.80 msIQR 5.536.084,000 rendersv8 wins: -22.7% time
200 next / prev moves with data10 steps2.57 msIQR 2.472.65200 renders2.39 msIQR 2.382.50200 rendersv8 wins: -6.6% time

Guards and boundaries

The guards return true, false, or Promise.resolve(true). The async fixture introduces a microtask, with no artificial delay or network request. The boundary fixture calls prev() at the first step. Results, visited ids, pending state after settlement, and guard call counts are checked for every trial.

A blocked action can still cause rendering when a version exposes a pending state while evaluating its guard. The render counts show that work as well as accepted transitions.

Guards and boundaries: v7 and v8 measurements. Lower values mean less time, renders, or code.
Scenariov7.0.0v8.0.0v8 vs v7
200 moves · accepting sync guard10 steps3.92 msIQR 3.743.98400 renders2.45 msIQR 2.192.51200 rendersv8 wins: -37.4% time
200 attempts · blocking sync guard10 steps3.83 msIQR 3.683.90400 renders1.30 msIQR 1.241.380 rendersv8 wins: -66.2% time
200 moves · accepting microtask guard10 steps3.76 msIQR 3.593.89400 renders3.92 msIQR 3.624.06400 rendersv8 loses: +4.3% time
200 prev attempts at the first step10 steps1.10 msIQR 1.071.150 renders1.10 msIQR 1.081.110 rendersv8 loses: +0.5% time

Reset recipes

v7 and v8 have different reset defaults. These cases compare matching outcomes explicitly:

  • Preserve data: 100 pairs of goTo(last) and reset, so 200 transitions. v7 uses reset(); v8 uses reset({ keepData: true, keepCompleted: true }). Both must retain the modified draft and completion flag. Navigation out and back is timed.
  • Full reset: before each reset, write a dirty draft, complete the first step, and move to the last step. This setup is outside the timer. v7 composes data.reset(), setComplete("step-0", false) and reset() in one synchronous batch; v8 calls reset(). Both must restore the initial id, the initial "seed" draft, and empty completion state. The displayed time is the sum of 100 reset intervals, including one event-loop turn per reset. This compares application recipes with the same outcome, not identical API calls.
Reset recipes: v7 and v8 measurements. Lower values mean less time, renders, or code.
Scenariov7.0.0v8.0.0v8 vs v7
100 goTo / reset pairs · preserve data10 steps2.47 msIQR 2.402.54200 renders2.42 msIQR 2.372.52200 rendersv8 wins: -2.0% time
100 full resets · setup excluded10 steps1.28 msIQR 1.241.32100 renders1.21 msIQR 1.171.26100 rendersv8 wins: -5.4% time

Schema validation

Call definition.validate() 200 times against the last of 100 steps. A shared, minimal Standard Schema fixture accepts numbers and rejects strings. The harness checks every validated value or issue. This isolates step lookup and the validation adapter; it does not benchmark Zod, Valibot or a complete form. No React instance is mounted for these two cases.

Schema validation: v7 and v8 measurements. Lower values mean less time, renders, or code.
Scenariov7.0.0v8.0.0v8 vs v7
200 valid schema checks · last of 100 steps100 steps0.19 msIQR 0.180.200.03 msIQR 0.030.03v8 wins: -86.8% time
200 invalid schema checks · last of 100 steps100 steps0.18 msIQR 0.180.200.03 msIQR 0.020.03v8 wins: -86.5% time

Bundle size

Both sources are bundled as minified ES2022 ESM using the same esbuild version and the automatic JSX transform. This includes the public React entry, primitives and matching core implementation. React is external. Gzip uses level 9; KiB means 1,024 bytes. It is not the size of a specific application's tree-shaken import.

Bundle size: v7 and v8 measurements. Lower values mean less time, renders, or code.
Scenariov7.0.0v8.0.0v8 vs v7
Minified library + core, gzip3.67 KiB5.49 KiBv8 loses: +49.6% size

Smaller bundles with your own UI

Import defineStepper from @stepperize/react/headless when you render your own markup or React Native components. It keeps local hooks, Provider, context selectors, controlled state, guards, cancellation, data, completion and validation. It does not return Stepper UI primitives.

import { defineStepper } from "@stepperize/react/headless";

const checkout = defineStepper([
  { id: "shipping", title: "Shipping" },
  { id: "payment", title: "Payment" },
]);

export function Checkout() {
  const stepper = checkout.useStepper();
  return (
    <section aria-label="Checkout">
      <h2>{stepper.current.title}</h2>
      <button
        type="button"
        disabled={!stepper.canNext}
        onClick={() => void stepper.next()}
      >
        Next
      </button>
    </section>
  );
}

Sharing works the same way: wrap consumers in checkout.Provider and read checkout.useStepperContext(selector). Use the main @stepperize/react entry when you want checkout.Stepper.Root, Trigger, Content or the other accessible primitives. Define a flow once and share that definition across its consumers; calling both factories creates independent flows.

v8 full entry vs v8 headless, including core. React is external.
Sizev8 fullv8 headlessHeadless vs full
Minified13.68 KiB8.45 KiBHeadless wins: -38.3% size
Minified + gzip5.49 KiB3.62 KiBHeadless wins: -34.0% size

This is v8 full vs v8 headless, not a like-for-like v7 comparison: the smaller entry excludes the UI layer. The full v7/v8 comparison remains above. Bundlers cannot generally remove individual properties returned by a factory, so importing only defineStepper from the main entry still includes its generated primitives. The headless entry separates that module graph without disabling state features. See esbuild's tree-shaking documentation.

If your app also imports the full entry, its primitives remain in the app's bundle; the saving applies when the UI layer is unused. Final transfer size also depends on your bundler, compression and application code.

Run pnpm benchmark:size for a quick check of built public exports, their shared chunks, SSR and a minimum 20% gzip saving for headless. It prints minified and gzip sizes without rerunning runtime timings. These built-artifact numbers can differ slightly from the source comparison above because the package build also transforms and splits modules.

The source benchmark now fixes JSX to automatic for both versions, matching the library build. Earlier reports that relied on the temporary snapshot's implicit JSX default are not directly comparable.

Timing and correctness

  • React runs in production mode, without Strict Mode or React Compiler transformations. Every timed sample starts with a fresh React root and fresh component instances, except the definition-only schema fixtures.
  • Container/root creation, initial mount and final unmount are excluded from update timings. Mount scenarios time root.render() through the synchronous commit. Full resets exclude the per-reset dirty-state setup.
  • Data writes and completion toggles use flushSync, forcing a commit per write instead of batching the whole loop. Transition calls also begin inside flushSync, then await the result and one MessageChannel task. This lets guarded and controlled updates commit before the next call. It is a measurement choice, not a recommendation for application code.
  • Each version gets five warmup samples and 21 recorded samples per scenario. Version order alternates every round. Trials run sequentially in one browser test. Chromium launches with --js-flags=--expose-gc. Explicit garbage collection runs before each sample, outside the timer; collection can still happen inside a timed interval.
  • Times use browser performance.now() with cross-origin isolation enabled for higher timer precision. Each table shows the median and the interquartile range (IQR). No outliers are removed. Render counts exclude initial mounting, except in mount scenarios. Shared counts include readers only; local counts include the local consumer. If counts vary, the table shows their observed range.
  • State, DOM output, transition results, every visited step, guard calls, draft values, completion flags and validation results are checked outside the measured intervals. Failures stop the benchmark before replacing the result files. Timed navigation loops include collecting outcomes and visited ids for those assertions.
  • The raw JSON contains all individual samples, per-scenario fixture dimensions, min/max render counts, environment details and source hashes. Changing the harness or fixtures produces a new measurement; do not mix it with earlier runs.

References: React's flushSync contract and caveats, Vitest Browser Mode, High Resolution Time, MessageChannel.

Limits

These tests do not isolate layout or paint costs, or measure frame rate, input latency, network transfer, mobile hardware, SSR/hydration, real-world schema complexity or guards with I/O. The async guard measures Promise handling without an external service. Forced commits and explicit event-loop turns differ from normal application batching and scheduling.

Results are a dated snapshot, not a live benchmark in your browser. CPU load, JIT compilation, scheduling and garbage collection affect small timings and percentage changes. Re-run with your actual UI before drawing conclusions about application responsiveness. The timing deltas are descriptive, not statistical significance claims or CI pass/fail thresholds.

Reproduce

From this repository, with the tested v8 source checkout and the pinned v7 commit available in Git:

nvm use
pnpm install --frozen-lockfile
pnpm test:install
pnpm benchmark

The terminal report shows aligned tables for timing, render counts and gzip size. ✓ WIN means v8 has a lower measured value, ✗ LOSS means a higher value, and = TIE means equal values. The signed percentage compares v8 with v7; these labels describe the measurements, not statistical significance. Interactive terminals use green/red indicators; redirected output stays plain, and NO_COLOR=1 disables colors. Raw JSON remains available for analysis and powers this page.

The benchmark bundles one shared production React/React DOM runtime with both versions. Vitest orchestrates the browser test; browser automation actions and assertion retries are outside the timed work.

The command reads v7 files from Git and v8 files from the working tree, then compiles temporary copies. It does not switch branches, alter library source, or fetch another v7 package. It uses workspace dependencies and replaces the raw JSON and the compact JSON used by this documentation page after all scenarios pass.

Source hashes, the harness hash and the lockfile hash identify the inputs. A different source snapshot or environment is a new measurement, so expect numbers to change.

Measured environment

Measured at
2026-09-15T19:40:21.830Z
Runtime
Chromium 153.0.8010.12, React / React DOM 19.3.0; Vitest 5.0.1, Playwright 1.63.0; Node v24.18.0 orchestrator
Machine
Apple M4 Max, arm64, darwin 25.5.0
Fixtures
21 scenarios; each row records its step count, operations, readers and mount count in the raw JSON
Sampling
5 warmups + 21 measured samples per version and scenario
Bundler
esbuild 0.27.2, ESM, ES2022, minified, gzip level 9
v7 git commit
f1b5bfafa5d585de47f6c70eeeb355b76b20ff24
v7 source SHA-256
11b4aac450cf4e65146171e5d4490295dadf16efdfd50e38f6acf6fe95a776a2
v8 source SHA-256
b74b554221dd492f7c57f4b16f08b1feadbf862627f7ef0c6c1ff4e8283a9f3f
Harness SHA-256
275dd9153c36f850a6dffeea23e5efc6c64611d9b92673094850ed6f52c2291a
Lockfile SHA-256
6b88c18483aabeaeb951567651f888397e89e8692420485f0545c098f4b56de8

Apply selectors to your flow

Use context selectors for consumers that only need a specific value. Keep subscriptions as broad as the UI requires. The local and shared state guide shows how to choose who owns the instance.

Edit on GitHub

Last updated on

On this page