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
Copy file name to clipboardExpand all lines: src/blog/tanstack-router-signal-graph.md
+81-31Lines changed: 81 additions & 31 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -10,11 +10,15 @@ title: 'From One Big Router Store to a Granular Signal Graph'
10
10
11
11

12
12
13
-
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.
13
+
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
14
15
15
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.
16
16
17
-
The result is better update locality, fewer store updates during navigation, substantially faster client-side navigation, and Solid can now use its native signals.
17
+
The result is
18
+
- better update locality,
19
+
- fewer store updates during navigation,
20
+
- substantially faster client-side navigation,
21
+
- and Solid can now use its native signals.
18
22
19
23
## Old Model: One Broad Router State
20
24
@@ -27,7 +31,7 @@ That was useful. It made it possible to prototype features quickly and ship a br
| Match lifecycle |`matches`, `pendingMatches`, `cachedMatches`|`useMatch`, `Matches`, `Outlet`|
29
33
| Navigation status |`status`, `isLoading`, `isTransitioning`| pending UI, transitions |
30
-
|Routing side effects |`redirect`, `statusCode`| navigation and response handling |
34
+
|Side effects |`redirect`, `statusCode`| navigation and response handling |
31
35
32
36
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.
33
37
@@ -48,16 +52,17 @@ A video showing that on every stateful event in the core of the router, changes
48
52
49
53
The point is that `router.state` was broader than what many consumers actually needed.
50
54
51
-
## New Model: Split Router State into Stores
55
+
## New Model: The Graph Becomes the Source of Truth
52
56
53
-
The new model breaks that broad surface into smaller stores with narrower responsibilities.
57
+
The new model is not just "more stores". It inverts the relationship between `router.state` and the reactive graph.
54
58
55
-
- top-level stores for location, status, loading, transitions, redirects, and similar scalar state
56
-
- separate pools for active matches, pending matches, and cached matches
57
-
- per-match stores inside each pool
58
-
- derived stores for route-level lookups such as "the current active match for route X"
59
+
The broad surface is split into smaller stores with narrower responsibilities.
59
60
60
-
`router.state` still exists as a compatibility view for public APIs. It is just no longer the primary model that everything else hangs off.
61
+
-**top-level stores** for location, status, loading, transitions, redirects, and similar scalar state
62
+
-**per-match stores** grouped into pools of active matches, pending matches, and cached matches.
63
+
-**derived stores** for specific purposes like "is any match pending"
64
+
65
+
`router.state` still exists as a compatibility snapshot for public APIs. It is just no longer the primary model that everything else hangs off.
61
66
62
67
The new picture looks like this:
63
68
@@ -69,14 +74,14 @@ A video showing that on stateful event in the core of the router, only specific
69
74
</figure>
70
75
71
76
> [!NOTE]
72
-
> Active, pending, and cached matches are now modeled differently because
77
+
> Active, pending, and cached matches are now modeled separately because
73
78
> they have different lifecycles. This reduces state propagation even further.
74
79
75
-
The important change is simple: the compatibility snapshot is now derived from the graph, instead of the graph being derived from the snapshot.
80
+
Before, the graph was derived from `router.state`. Now, `router.state`is derived from the graph. That inversion is the refactor.
76
81
77
82
## Hook-Level Change: Subscribe to the Relevant Store
78
83
79
-
The clearest example is `useMatch`.
84
+
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`.
80
85
81
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.
82
87
@@ -96,21 +101,36 @@ useStore(matchStore, (match) => /* select from one match */)
96
101
> in a Least-Recently-Used cache so it can be reused by other subscribers
97
102
> without leaking memory.
98
103
99
-
The store-update-count graphs below show the before/after change within each adapter. Absolute counts are not directly comparable across frameworks, because React, Solid, and Vue do not propagate updates in exactly the same way.
104
+
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).
100
105
101
106
<!-- ::start:tabs -->
102
107
103
108
#### React
104
109
105
-

110
+
<figure>
111
+
<imgsrc="/blog-assets/tanstack-router-signal-graph/store-updates-history-react.png"alt="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">
112
+
<figcaption>
113
+
Absolute counts are not directly comparable across frameworks, because React, Solid, and Vue do not propagate updates in exactly the same way.
114
+
</figcaption>
115
+
</figure>
106
116
107
117
#### Solid
108
118
109
-

119
+
<figure>
120
+
<imgsrc="/blog-assets/tanstack-router-signal-graph/store-updates-history-solid.png"alt="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">
121
+
<figcaption>
122
+
Absolute counts are not directly comparable across frameworks, because React, Solid, and Vue do not propagate updates in exactly the same way.
123
+
</figcaption>
124
+
</figure>
110
125
111
126
#### Vue
112
127
113
-

128
+
<figure>
129
+
<imgsrc="/blog-assets/tanstack-router-signal-graph/store-updates-history-vue.png"alt="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">
130
+
<figcaption>
131
+
Absolute counts are not directly comparable across frameworks, because React, Solid, and Vue do not propagate updates in exactly the same way.
This keeps one router core while letting each adapter plug in the store model it wants.
151
170
152
171
> [!NOTE]
153
-
> Solid's derived stores are backed by native memos, and the adapter uses a `FinalizationRegistry`
172
+
> Solid's derived stores are backed by native memos, and the adapter uses a `FinalizationRegistry`
154
173
> to dispose detached roots when those stores are garbage-collected.
155
174
156
175
## Observable Result: Less Work During Navigation
157
176
158
177
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.
159
178
160
-
The `benchmarks/client-nav` benchmarks isolate client-side navigation cost on a synthetic rerender-heavy page.
179
+
Our benchmarks isolate client-side navigation cost on a synthetic rerender-heavy page.
161
180
162
181
- React: `7ms -> 4.5ms`
163
182
- Solid: `12ms -> 8ms`
@@ -167,15 +186,31 @@ The `benchmarks/client-nav` benchmarks isolate client-side navigation cost on a
167
186
168
187
#### React
169
188
170
-

This graph shows the duration of 10 navigations going from 70ms on <code>main</code> (grey) to 45ms on <code>refactor-signals</code> (blue).
193
+
</figcaption>
194
+
</figure>
171
195
172
196
#### Solid
173
197
174
-

This graph shows the duration of 10 navigations going from 120ms on <code>main</code> (grey) to 80ms on <code>refactor-signals</code> (blue).
202
+
</figcaption>
203
+
</figure>
175
204
176
205
#### Vue
177
206
178
-

This graph shows the duration of 10 navigations going from 75ms on <code>main</code> (grey) to 60ms on <code>refactor-signals</code> (blue).
212
+
</figcaption>
213
+
</figure>
179
214
180
215
<!-- ::end:tabs -->
181
216
@@ -189,22 +224,37 @@ There is also a bundle-size tradeoff. In our synthetic bundle-size benchmarks, m
189
224
190
225
#### React
191
226
192
-

227
+
<figure>
228
+
<img src="/blog-assets/tanstack-router-signal-graph/bundle-size-history-react.png" alt="A graph the history of the bundle size of a synthetic tanstack/react-router app, gaining 1KiB gzipped with this latest change">
229
+
<figcaption>
230
+
Only relative changes matter in this benchmark, they are based on arbitrary apps and absolute sizes are not representative.
231
+
</figcaption>
232
+
</figure>
193
233
194
234
#### Solid
195
235
196
-

236
+
<figure>
237
+
<img src="/blog-assets/tanstack-router-signal-graph/bundle-size-history-solid.png" alt="A graph the history of the bundle size of a synthetic tanstack/solid-router app, shedding 1KiB gzipped with this latest change">
238
+
<figcaption>
239
+
Only relative changes matter in this benchmark, they are based on arbitrary apps and absolute sizes are not representative.
240
+
</figcaption>
241
+
</figure>
197
242
198
243
#### Vue
199
244
200
-

245
+
<figure>
246
+
<img src="/blog-assets/tanstack-router-signal-graph/bundle-size-history-vue.png" alt="A graph the history of the bundle size of a synthetic tanstack/vue-router app, gaining 1KiB gzipped with this latest change">
247
+
<figcaption>
248
+
Only relative changes matter in this benchmark, they are based on arbitrary apps and absolute sizes are not representative.
249
+
</figcaption>
250
+
</figure>
201
251
202
252
<!-- ::end:tabs -->
203
253
204
254
## Closing
205
255
206
-
This refactor did not just add signals to the old model. It also changed the reactive model itself.
256
+
This refactor did not just add signals to the old model. It inverted the reactivity model.
207
257
208
-
The old model organized most reactivity around one broad router state. The new model organizes it around a graph of smaller stores with narrower update paths.
258
+
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.
209
259
210
260
Routing is a graph. Now the reactivity is one too.
0 commit comments