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 resultsRead 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
useSyncExternalStoreand 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.
| Scenario | v7.0.0 | v8.0.0 | v8 vs v7 |
|---|---|---|---|
| Mount 100 local flows10 steps | 0.33 msIQR 0.29–0.45100 renders | 0.28 msIQR 0.26–0.31100 renders | v8 wins: -13.6% time |
| Mount 100 local flows · 100 steps100 steps | 0.46 msIQR 0.36–0.48100 renders | 0.28 msIQR 0.23–0.29100 renders | v8 wins: -39.1% time |
| 200 local data writes10 steps | 0.49 msIQR 0.43–0.56200 renders | 0.56 msIQR 0.51–0.63200 renders | v8 loses: +15.5% time |
| 200 local data writes · 100 steps100 steps | 0.60 msIQR 0.49–0.69200 renders | 0.56 msIQR 0.49–0.60200 renders | v8 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 usesuseStepperContext((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.
| Scenario | v7.0.0 | v8.0.0 | v8 vs v7 |
|---|---|---|---|
| 200 data writes · 20 full-state readers10 steps | 4.84 msIQR 4.67–5.124,000 renders | 2.36 msIQR 2.34–2.554,000 renders | v8 wins: -51.4% time |
| 200 data writes · 20 id readers10 steps | 4.83 msIQR 4.36–5.014,000 renders | 0.60 msIQR 0.57–0.640 renders | v8 wins: -87.6% time |
| 200 data writes · 100 id readers10 steps | 15.07 msIQR 14.41–18.0320,000 renders | 1.34 msIQR 1.20–1.380 renders | v8 wins: -91.1% time |
| 200 data writes · 20 data readers10 steps | 4.85 msIQR 4.72–5.214,000 renders | 3.11 msIQR 2.99–3.174,000 renders | v8 wins: -35.9% time |
| 200 completion toggles · 20 completion readers10 steps | 4.92 msIQR 4.41–5.234,000 renders | 3.03 msIQR 2.94–3.124,000 renders | v8 wins: -38.4% time |
Navigation
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.
| Scenario | v7.0.0 | v8.0.0 | v8 vs v7 |
|---|---|---|---|
| 200 next / prev moves10 steps | 2.61 msIQR 2.47–2.73200 renders | 2.38 msIQR 2.32–2.45200 renders | v8 wins: -9.0% time |
| 200 first / last jumps · 100 steps100 steps | 4.58 msIQR 4.47–4.74200 renders | 2.34 msIQR 2.30–2.51200 renders | v8 wins: -48.9% time |
| 200 controlled moves · 20 id readers10 steps | 7.50 msIQR 7.18–8.184,000 renders | 5.80 msIQR 5.53–6.084,000 renders | v8 wins: -22.7% time |
| 200 next / prev moves with data10 steps | 2.57 msIQR 2.47–2.65200 renders | 2.39 msIQR 2.38–2.50200 renders | v8 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.
| Scenario | v7.0.0 | v8.0.0 | v8 vs v7 |
|---|---|---|---|
| 200 moves · accepting sync guard10 steps | 3.92 msIQR 3.74–3.98400 renders | 2.45 msIQR 2.19–2.51200 renders | v8 wins: -37.4% time |
| 200 attempts · blocking sync guard10 steps | 3.83 msIQR 3.68–3.90400 renders | 1.30 msIQR 1.24–1.380 renders | v8 wins: -66.2% time |
| 200 moves · accepting microtask guard10 steps | 3.76 msIQR 3.59–3.89400 renders | 3.92 msIQR 3.62–4.06400 renders | v8 loses: +4.3% time |
| 200 prev attempts at the first step10 steps | 1.10 msIQR 1.07–1.150 renders | 1.10 msIQR 1.08–1.110 renders | v8 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 usesreset(); v8 usesreset({ 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)andreset()in one synchronous batch; v8 callsreset(). 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.
| Scenario | v7.0.0 | v8.0.0 | v8 vs v7 |
|---|---|---|---|
| 100 goTo / reset pairs · preserve data10 steps | 2.47 msIQR 2.40–2.54200 renders | 2.42 msIQR 2.37–2.52200 renders | v8 wins: -2.0% time |
| 100 full resets · setup excluded10 steps | 1.28 msIQR 1.24–1.32100 renders | 1.21 msIQR 1.17–1.26100 renders | v8 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.
| Scenario | v7.0.0 | v8.0.0 | v8 vs v7 |
|---|---|---|---|
| 200 valid schema checks · last of 100 steps100 steps | 0.19 msIQR 0.18–0.20 | 0.03 msIQR 0.03–0.03 | v8 wins: -86.8% time |
| 200 invalid schema checks · last of 100 steps100 steps | 0.18 msIQR 0.18–0.20 | 0.03 msIQR 0.02–0.03 | v8 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.
| Scenario | v7.0.0 | v8.0.0 | v8 vs v7 |
|---|---|---|---|
| Minified library + core, gzip | 3.67 KiB | 5.49 KiB | v8 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.
| Size | v8 full | v8 headless | Headless vs full |
|---|---|---|---|
| Minified | 13.68 KiB | 8.45 KiB | Headless wins: -38.3% size |
| Minified + gzip | 5.49 KiB | 3.62 KiB | Headless 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 insideflushSync, then await the result and oneMessageChanneltask. 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 benchmarkThe 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.
- Download every recorded sample and metadata
- Benchmark build and report script
- Measurement fixtures and correctness assertions
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.
Last updated on