You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
# title: 'Routing Is a Graph. Now Our Reactivity Is Too.'
8
8
title: 'From One Big Router Store to a Granular Signal Graph'
9
-
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.
9
+
excerpt: TanStack Router now uses a granular signal graph as its reactive core. State is derived from that graph, which narrows change propagation and makes client-side navigation faster in our benchmarks.
10
10
---
11
11
12
12

13
13
14
-
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.
14
+
TanStack Router used to keep all of its reactive state in one large object: `router.state`. [This refactor](https://github.com/TanStack/router/pull/6704) replaces that with a graph of smaller stores for the pieces of state that change independently. `router.state`still exists, but it is now derived from those stores instead of serving as the internal source of truth.
15
15
16
-
This builds on TanStack Store's migration[^alien-migration] to [alien-signals](https://github.com/stackblitz/alien-signals), implemented by [@DavidKPiano](https://github.com/davidkpiano). In external benchmarks[^alien-bench], alien-signals is one of the best-performing signals implementations tested. But the main improvement here is not just a faster primitive. It is a different reactive model.
16
+
This builds on TanStack Store's migration[^alien-migration] to [alien-signals](https://github.com/stackblitz/alien-signals), implemented by [@DavidKPiano](https://github.com/davidkpiano). In external benchmarks[^alien-bench], alien-signals performed very well. The faster primitive helps, but the bigger change is that this allows the router to track state in smaller pieces instead of routing everything through one broad store.
17
17
18
-
The result is
18
+
Concretely, this means:
19
19
20
-
-better update locality,
20
+
-more targeted updates,
21
21
- fewer store updates during navigation,
22
-
-substantially faster client-side navigation,
22
+
- faster client-side navigation in our benchmarks,
23
23
- the Solid adapter now uses native Solid signals internally.
24
24
25
25
## Old Model: One Broad Router State
@@ -35,13 +35,13 @@ That was useful. It made it possible to prototype features quickly and ship a br
35
35
| Navigation status |`status`, `isLoading`, `isTransitioning`| pending UI, transitions |
36
36
| Side effects |`redirect`, `statusCode`| navigation and response handling |
37
37
38
-
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.
38
+
This did not mean every update rerendered everything. Options like `select` and `structuralSharing` could prevent propagation. But many consumers still subscribed to more router state than they actually needed.
39
39
40
-
## Problem: Routing State Has Locality
40
+
## Problem: Routing State Changes in Smaller Pieces
41
41
42
-
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.
42
+
Routing state does not change as one unit. During a navigation, one match stays active, another becomes pending, one link changes state, and some cached matches do not change at all.
43
43
44
-
The old model captured those pieces of state, but it flattened them into one main subscription surface. This is where the mismatch becomes visible:
44
+
The old model captured those pieces of state, but all subscriptions still started from the same top-level state object. That mismatch shows up here:
@@ -50,19 +50,19 @@ A video showing that on every stateful event in the core of the router, changes
50
50
</figcaption>
51
51
</figure>
52
52
53
-
The point is that `router.state` was broader than what many consumers actually needed.
53
+
In practice, many consumers subscribed to more router state than they actually needed.
54
54
55
-
## New Model: The Graph Becomes the Source of Truth
55
+
## New Model: Smaller Stores Become the Source of Truth
56
56
57
-
The new model is not just "more stores". It inverts the relationship between `router.state`and the reactive graph.
57
+
The main change is that the smaller stores are now the source of truth, and `router.state`is rebuilt from them.
58
58
59
-
The broad surface is split into smaller stores with narrower responsibilities.
59
+
Instead of one broad state object, the router keeps separate stores with narrower responsibilities.
60
60
61
61
-**top-level stores** for location, status, loading, transitions, redirects, and similar scalar state
62
62
-**per-match stores** grouped into pools of active matches, pending matches, and cached matches.
63
63
-**derived stores** for specific purposes like "is any match pending"
64
64
65
-
`router.state` still exists for public APIs, but it is now recomputed from the store graph instead of serving as the internal source of truth.
65
+
`router.state` still exists for public APIs, but it is now rebuilt from the store graph instead of serving as the internal source of truth.
66
66
67
67
The new picture looks like this:
68
68
@@ -75,13 +75,13 @@ A video showing that on each stateful event in the core of the router, only a sp
75
75
76
76
> [!NOTE]
77
77
> Active, pending, and cached matches are now modeled separately because
78
-
> they have different lifecycles. This reduces state propagation even further.
78
+
> they have different lifecycles. This cuts down updates even further.
79
79
80
-
Before, the graph was derived from `router.state`. Now, `router.state` is derived from the graph. That inversion is the refactor.
80
+
Before, the smaller pieces of state were derived from `router.state`. Now, `router.state` is derived from the smaller stores. That is the core of this refactor.
81
81
82
82
## Hook-Level Change: Subscribe to the Relevant Store
83
83
84
-
Once the graph becomes the source of truth, router internals can subscribe directly to graph nodes instead of selecting from a broad snapshot. The clearest example is `useMatch`.
84
+
With the smaller stores as the source of truth, router internals can subscribe to the exact store they need instead of selecting from one large snapshot. The clearest example is `useMatch`.
85
85
86
86
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.
87
87
@@ -102,11 +102,11 @@ useStore(matchStore, (match) => /* select from one match */)
102
102
This is an internal implementation detail, not a new public API surface for application code.
103
103
104
104
> [!NOTE]
105
-
> `getMatchStoreByRouteId` creates a derived signal on demand, and stores it
106
-
> in a Least-Recently-Used cache[^lru-cache] so it can be reused by other subscribers
105
+
> `getMatchStoreByRouteId` creates the derived signal on demand and stores it
106
+
> in a Least-Recently-Used cache[^lru-cache] so other subscribers can reuse it
107
107
> without leaking memory.
108
108
109
-
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).[^store-update-tests]
109
+
The store-update-count graphs below show how many times subscriptions are invoked during various routing scenarios. The last point is this refactor.[^store-update-tests]
110
110
111
111
<!-- ::start:tabs -->
112
112
@@ -139,13 +139,13 @@ Absolute counts are not directly comparable across frameworks, because React, So
139
139
140
140
<!-- ::end:tabs -->
141
141
142
-
These graphs show that change propagation got narrower.
142
+
These graphs show that fewer subscribers are triggered during navigation.
143
143
144
144
## Store Boundary: One Contract, Multiple Implementations
145
145
146
-
The refactor did not only split router state into smaller stores. It also moved the store implementation behind a contract.
146
+
The refactor also moves the store implementation behind a shared contract.
147
147
148
-
The core now defines what a router store must do. Each adapter provides the implementation.
148
+
The router core defines the interface. Each adapter provides the implementation.
149
149
150
150
```ts
151
151
exportinterfaceRouterReadableStore<TValue> {
@@ -179,7 +179,7 @@ This keeps one router core while letting each adapter plug in the store model it
179
179
180
180
## Observable Result: Less Work During Navigation
181
181
182
-
No new public API is required here. `useMatch`, `useLocation`, and `<Link>` keep the same surface. The difference is that navigation and preload flows now wake up fewer subscriptions.
182
+
No new public API is required here. `useMatch`, `useLocation`, and `<Link>` keep the same surface. The difference is that navigation and preload flows now trigger fewer subscriptions.
183
183
184
184
Our benchmarks isolate client-side navigation cost on a synthetic rerender-heavy page.[^client-nav-bench]
185
185
@@ -224,6 +224,8 @@ There is also a bundle-size tradeoff. In our synthetic bundle-size benchmarks, m
224
224
- ↗ Vue increased by `~1KiB`
225
225
- ↘ Solid decreased by `~1KiB`
226
226
227
+
React and Vue increased in size because representing the router as several stores takes more code than representing it as one state object. Solid decreased in size because it no longer depends on `tanstack/store`.
228
+
227
229
<!-- ::start:tabs -->
228
230
229
231
#### React
@@ -257,11 +259,11 @@ Only relative changes matter in this benchmark, they are based on arbitrary apps
257
259
258
260
## Closing
259
261
260
-
This refactor did not just add signals to the old model. It inverted the reactivity model.
262
+
This refactor changes how reactivity is structured inside the router.
261
263
262
-
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.
264
+
Before, `router.state` was the broad reactive surface and smaller pieces of state were derived from it. Now the smaller stores are primary, and `router.state` is a derived snapshot kept for the existing public API.
263
265
264
-
Routing is a graph. Now the reactivity is one too.
266
+
In practice, that means route changes update more locally and trigger less work during navigation.
0 commit comments