diff --git a/public/blog-assets/tanstack-router-signal-graph/after-granular-store-graph.mp4 b/public/blog-assets/tanstack-router-signal-graph/after-granular-store-graph.mp4 new file mode 100644 index 00000000..6764956d Binary files /dev/null and b/public/blog-assets/tanstack-router-signal-graph/after-granular-store-graph.mp4 differ diff --git a/public/blog-assets/tanstack-router-signal-graph/before-router-state-blob.mp4 b/public/blog-assets/tanstack-router-signal-graph/before-router-state-blob.mp4 new file mode 100644 index 00000000..45b4a203 Binary files /dev/null and b/public/blog-assets/tanstack-router-signal-graph/before-router-state-blob.mp4 differ diff --git a/public/blog-assets/tanstack-router-signal-graph/bundle-size-history-react.png b/public/blog-assets/tanstack-router-signal-graph/bundle-size-history-react.png new file mode 100644 index 00000000..af9c4c31 Binary files /dev/null and b/public/blog-assets/tanstack-router-signal-graph/bundle-size-history-react.png differ diff --git a/public/blog-assets/tanstack-router-signal-graph/bundle-size-history-solid.png b/public/blog-assets/tanstack-router-signal-graph/bundle-size-history-solid.png new file mode 100644 index 00000000..93f7f4f4 Binary files /dev/null and b/public/blog-assets/tanstack-router-signal-graph/bundle-size-history-solid.png differ diff --git a/public/blog-assets/tanstack-router-signal-graph/bundle-size-history-vue.png b/public/blog-assets/tanstack-router-signal-graph/bundle-size-history-vue.png new file mode 100644 index 00000000..81857b21 Binary files /dev/null and b/public/blog-assets/tanstack-router-signal-graph/bundle-size-history-vue.png differ diff --git a/public/blog-assets/tanstack-router-signal-graph/client-side-nav-react.png b/public/blog-assets/tanstack-router-signal-graph/client-side-nav-react.png new file mode 100644 index 00000000..4e45156f Binary files /dev/null and b/public/blog-assets/tanstack-router-signal-graph/client-side-nav-react.png differ diff --git a/public/blog-assets/tanstack-router-signal-graph/client-side-nav-solid.png b/public/blog-assets/tanstack-router-signal-graph/client-side-nav-solid.png new file mode 100644 index 00000000..6e6c72a8 Binary files /dev/null and b/public/blog-assets/tanstack-router-signal-graph/client-side-nav-solid.png differ diff --git a/public/blog-assets/tanstack-router-signal-graph/client-side-nav-vue.png b/public/blog-assets/tanstack-router-signal-graph/client-side-nav-vue.png new file mode 100644 index 00000000..6992a459 Binary files /dev/null and b/public/blog-assets/tanstack-router-signal-graph/client-side-nav-vue.png differ diff --git a/public/blog-assets/tanstack-router-signal-graph/header.png b/public/blog-assets/tanstack-router-signal-graph/header.png new file mode 100644 index 00000000..7924f7e8 Binary files /dev/null and b/public/blog-assets/tanstack-router-signal-graph/header.png differ diff --git a/public/blog-assets/tanstack-router-signal-graph/store-updates-history-react.png b/public/blog-assets/tanstack-router-signal-graph/store-updates-history-react.png new file mode 100644 index 00000000..8eebbb72 Binary files /dev/null and b/public/blog-assets/tanstack-router-signal-graph/store-updates-history-react.png differ diff --git a/public/blog-assets/tanstack-router-signal-graph/store-updates-history-solid.png b/public/blog-assets/tanstack-router-signal-graph/store-updates-history-solid.png new file mode 100644 index 00000000..8acaef18 Binary files /dev/null and b/public/blog-assets/tanstack-router-signal-graph/store-updates-history-solid.png differ diff --git a/public/blog-assets/tanstack-router-signal-graph/store-updates-history-vue.png b/public/blog-assets/tanstack-router-signal-graph/store-updates-history-vue.png new file mode 100644 index 00000000..ad03c507 Binary files /dev/null and b/public/blog-assets/tanstack-router-signal-graph/store-updates-history-vue.png differ diff --git a/src/blog/tanstack-router-signal-graph.md b/src/blog/tanstack-router-signal-graph.md new file mode 100644 index 00000000..f1e288a9 --- /dev/null +++ b/src/blog/tanstack-router-signal-graph.md @@ -0,0 +1,262 @@ +--- +published: 2026-03-15 +authors: + - Florian Pellet +# title: 'How TanStack Router Became Granularly Reactive' +# title: 'TanStack Router''s Granular Reactivity Rewrite' +# title: 'Routing Is a Graph. Now Our Reactivity Is Too.' +title: 'From One Big Router Store to a Granular Signal Graph' +excerpt: TanStack Router now uses a granular signal graph as its reactive core. State is derived from that graph, narrowing change propagation and making client-side navigation substantially faster. +--- + +![veins of emerald as a signal graph embedded in the rock of a tropical island](/blog-assets/tanstack-router-signal-graph/header.png) + +TanStack Router used to center most of its reactivity around one large object: `router.state`. [This refactor](https://github.com/TanStack/router/pull/6704) replaces that broad store with a graph of smaller stores. `router.state` is no longer the internal source of truth. It is now derived from the store graph. + +This builds on TanStack Store's migration to [alien-signals](https://github.com/stackblitz/alien-signals) in [TanStack Store PR #265](https://github.com/TanStack/store/pull/265), implemented by [@DavidKPiano](https://github.com/davidkpiano). In external benchmarks like [js-reactivity-benchmark](https://github.com/transitive-bullshit/js-reactivity-benchmark), alien-signals is currently the best-performing signals implementation tested. But the main improvement here is not just a faster primitive. It is a different reactive model. + +The result is + +- better update locality, +- fewer store updates during navigation, +- substantially faster client-side navigation, +- and Solid can now use its native signals. + +## Old Model: One Broad Router State + +The old model had one main reactive surface: `router.state`. + +That was useful. It made it possible to prototype features quickly and ship a broad API surface without first designing a perfect internal reactive topology. But it also meant many different concerns shared the same reactive entry point. + +| Concern | Stored under `router.state` | Typical consumer | +| ----------------- | -------------------------------------------- | -------------------------------- | +| Location | `location`, `resolvedLocation` | `useLocation`, `Link` | +| Match lifecycle | `matches`, `pendingMatches`, `cachedMatches` | `useMatch`, `Matches`, `Outlet` | +| Navigation status | `status`, `isLoading`, `isTransitioning` | pending UI, transitions | +| Side effects | `redirect`, `statusCode` | navigation and response handling | + +This did not mean every update rerendered everything. Options like `select` and `structuralSharing` could prevent propagation. But many consumers still started from a broader subscription surface than they actually needed. + +## Problem: Routing State Has Locality + +Routing is not one thing that changes all at once. A navigation changes specific pieces of state with specific relationships: one match stays active, another becomes pending, one link flips state, some cached matches do not change at all. + +The old model captured those pieces of state, but it flattened them into one main subscription surface. This is where the mismatch becomes visible: + +
+ +
+A video showing that on every stateful event in the core of the router, changes are propagated to every subscription across the entire application. +
+
+ +The point is that `router.state` was broader than what many consumers actually needed. + +## New Model: The Graph Becomes the Source of Truth + +The new model is not just "more stores". It inverts the relationship between `router.state` and the reactive graph. + +The broad surface is split into smaller stores with narrower responsibilities. + +- **top-level stores** for location, status, loading, transitions, redirects, and similar scalar state +- **per-match stores** grouped into pools of active matches, pending matches, and cached matches. +- **derived stores** for specific purposes like "is any match pending" + +`router.state` still exists as a compatibility snapshot for public APIs. It is just no longer the primary model that everything else hangs off. + +The new picture looks like this: + +
+ +
+A video showing that on each stateful event in the core of the router, only a specific subset of subscribers are updated in the application. +
+
+ +> [!NOTE] +> Active, pending, and cached matches are now modeled separately because +> they have different lifecycles. This reduces state propagation even further. + +Before, the graph was derived from `router.state`. Now, `router.state` is derived from the graph. That inversion is the refactor. + +## Hook-Level Change: Subscribe to the Relevant Store + +Once the graph becomes the source of truth, hooks can subscribe directly to graph nodes instead of selecting from a broad snapshot. The clearest example is `useMatch`. + +Before this refactor, `useMatch` subscribed through the big router store and then searched `state.matches` for the match it cared about. Now it resolves the relevant store first and subscribes directly to it. + +```ts +// Before +useRouterState({ + select: (state) => { + const match = state.matches.find((m) => m.routeId === routeId) + return /* select from one match */ + } +}) + +// After +const matchStore = router.stores.getMatchStoreByRouteId(routeId) +useStore(matchStore, (match) => /* select from one match */) +``` + +> [!NOTE] +> `getMatchStoreByRouteId` creates a derived signal on demand, and stores it +> in a Least-Recently-Used cache so it can be reused by other subscribers +> without leaking memory. + +The store-update-count graphs below show how many times subscriptions are invoked during various routing scenarios, before (curve is the entire history) and after (last point is this refactor). + + + +#### React + +
+A graph showing the number of times a useRouterState subscription is triggered in various test scenarios, going from a 5 to 18 range down to a 0 to 8 range +
+Absolute counts are not directly comparable across frameworks, because React, Solid, and Vue do not propagate updates in exactly the same way. +
+
+ +#### Solid + +
+A graph showing the number of times a useRouterState subscription is triggered in various test scenarios, going from a 3 to 19 range down to a 0 to 8 range +
+Absolute counts are not directly comparable across frameworks, because React, Solid, and Vue do not propagate updates in exactly the same way. +
+
+ +#### Vue + +
+A graph showing the number of times a useRouterState subscription is triggered in various test scenarios, going from a 6 to 46 range down to a 2 to 16 range +
+Absolute counts are not directly comparable across frameworks, because React, Solid, and Vue do not propagate updates in exactly the same way. +
+
+ + + +These graphs are the most direct proof that change propagation got narrower. + +## Store Boundary: One Contract, Multiple Implementations + +The refactor did not only split router state into smaller stores. It also moved the store implementation behind a contract. + +The core now defines what a router store must do. Each adapter provides the implementation. + +```ts +export interface RouterReadableStore { + readonly state: TValue +} + +export interface RouterWritableStore { + readonly state: TValue + setState: (updater: (prev: TValue) => TValue) => void +} + +export type StoreConfig = { + createMutableStore: MutableStoreFactory + createReadonlyStore: ReadonlyStoreFactory + batch: RouterBatchFn + init?: (stores: RouterStores) => void +} +``` + +| Adapter | Store implementation | +| :------ | :------------------- | +| React | TanStack Store | +| Vue | TanStack Store | +| Solid | Solid signals | + +This keeps one router core while letting each adapter plug in the store model it wants. + +> [!NOTE] +> Solid's derived stores are backed by native memos, and the adapter uses a `FinalizationRegistry` +> to dispose detached roots when those stores are garbage-collected. + +## Observable Result: Less Work During Navigation + +No new public API is required here. `useMatch`, `useLocation`, and `` keep the same surface. The difference is that navigation and preload flows now wake up fewer subscriptions. + +Our benchmarks isolate client-side navigation cost on a synthetic rerender-heavy page. + +- React: `7ms -> 4.5ms` +- Solid: `12ms -> 8ms` +- Vue: `7.5ms -> 6ms` + + + +#### React + +
+ +
+This graph shows the duration of 10 navigations on main (grey) and on refactor-signals (blue). +
+
+ +#### Solid + +
+ +
+This graph shows the duration of 10 navigations on main (grey) and on refactor-signals (blue). +
+
+ +#### Vue + +
+ +
+This graph shows the duration of 10 navigations on main (grey) and on refactor-signals (blue). +
+
+ + + +There is also a bundle-size tradeoff. In our synthetic bundle-size benchmarks, measuring gzipped sizes: + +- ↗ React increased by `~1KiB` +- ↗ Vue increased by `~1KiB` +- ↘ Solid decreased by `~1KiB` + + + +#### React + +
+A graph of the history of the bundle size of a synthetic tanstack/react-router app, gaining 1KiB gzipped with this latest change +
+Only relative changes matter in this benchmark, they are based on arbitrary apps and absolute sizes are not representative. +
+
+ +#### Solid + +
+A graph of the history of the bundle size of a synthetic tanstack/solid-router app, shedding 1KiB gzipped with this latest change +
+Only relative changes matter in this benchmark, they are based on arbitrary apps and absolute sizes are not representative. +
+
+ +#### Vue + +
+A graph of the history of the bundle size of a synthetic tanstack/vue-router app, gaining 1KiB gzipped with this latest change +
+Only relative changes matter in this benchmark, they are based on arbitrary apps and absolute sizes are not representative. +
+
+ + + +## Closing + +This refactor did not just add signals to the old model. It inverted the reactivity model. + +Before, `router.state` was the broad reactive surface and the graph was derived from it. Now the graph is the primary model, and `router.state` is a compatibility snapshot derived from the graph. + +Routing is a graph. Now the reactivity is one too.