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
+
title: 'From One Big Router Store to a Granular Signal Graph'
11
9
---
12
10
13
11

14
12
15
-
# Granular Signal Graph Blog Plan
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.
16
14
17
-
## Working Subtitle
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.
18
16
19
-
How we replaced one broad router-state subscription with per-match stores, injectable store implementations, and a model that matches how routing actually works more closely.
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.
20
18
21
-
## Article Shape
19
+
## Old Model: One Broad Router State
22
20
23
-
- Length: about 2,000–2,500 words
24
-
- Audience: engineering-heavy readers
25
-
- Tone: mechanism before marketing, confident but concrete
26
-
- Every claim should be substantiated by code, by measurements/graphs, or by an external trustworthy source
21
+
The old model had one main reactive surface: `router.state`.
27
22
28
-
## Core Thesis
23
+
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.
29
24
30
-
TanStack Router changed shape: from one coarse reactive router state to a granular store graph with explicit lifetimes and injectable store implementations. The performance wins matter because they validate a better architecture, not because the article is about benchmarks.
25
+
| Concern | Stored under `router.state`| Typical consumer |
| Match lifecycle |`matches`, `pendingMatches`, `cachedMatches`|`useMatch`, `Matches`, `Outlet`|
29
+
| Navigation status |`status`, `isLoading`, `isTransitioning`| pending UI, transitions |
30
+
| Routing side effects |`redirect`, `statusCode`| navigation and response handling |
31
31
32
-
---
33
-
34
-
## Tight Outline
35
-
36
-
### 1. Intro
37
-
38
-
**Section goal:** Set the stakes. This was not a "swap in signals" refactor; the router's internal reactive model changed shape.
39
-
40
-
**Transition out:** "To understand why that matters, you need to see what we were working with."
41
-
42
-
### 2. The Old Model
43
-
44
-
**Working title:**_One store to rule them all_
45
-
46
-
**Section goal:** Explain `router.state` as the old center of gravity. Location, active matches, pending matches, cached matches, route-level lookups — all bundled into one reactive object. That model was not just convenient, it was essential to quickly prototype and ship a lot of the APIs we like. But it also meant every consumer subscribed to essentially the same broad surface.
32
+
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.
47
33
48
-
**Transition out:** "This worked well, but it also meant the reactive model was broader than the underlying routing behavior."
34
+
## Problem: Routing State Has Locality
49
35
50
-
### 3. Why Routing Is Naturally Granular
36
+
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.
51
37
52
-
**Working title:**_The mismatch_
38
+
The old model captured those pieces of state, but it flattened them into one main subscription surface. That was the mismatch.
53
39
54
-
**Section goal:** Make the architectural argument. A navigation changes specific things with specific relationships: one match becomes pending, another stays active, a link flips, a leaf loads data. The old model collapsed all of that into one event. The reactive surface should reflect the actual locality of routing.
@@ -60,19 +46,20 @@ A video showing that on every stateful event in the core of the router, changes
60
46
</figcaption>
61
47
</figure>
62
48
63
-
**Transition out:** "So we redesigned the internals to match."
49
+
The point is that `router.state` was broader than what many consumers actually needed.
64
50
65
-
### 4. The New Model
51
+
##New Model: Split Router State into Stores
66
52
67
-
**Working title:**_A graph of stores_
53
+
The new model breaks that broad surface into smaller stores with narrower responsibilities.
68
54
69
-
**Section goal:** Introduce the new architecture. Top-level atoms for location and status. Separate active, pending, and cached match pools. Per-match stores. Per-route computed stores that derive "the current active match for route X" from the pool. `RouterState` still exists as a compatibility view but is no longer the real implementation.
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"
70
59
71
-
**Visuals:**
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.
@@ -81,27 +68,26 @@ A video showing that on stateful event in the core of the router, only specific
81
68
</figcaption>
82
69
</figure>
83
70
84
-
**Transition out:** "The most interesting piece of this is what happens at the per-route level."
71
+
The important change is simple: the compatibility snapshot is now derived from the graph, instead of the graph being derived from the snapshot.
85
72
86
-
### 5. Per-Match and Per-Route Reactivity
73
+
##Hook-Level Change: Subscribe to the Relevant Store
87
74
88
-
**Working title:**_Subscribe to what you need_
75
+
The clearest example is `useMatch`.
89
76
90
-
**Section goal:** This is the deepest technical section and the clearest expression of the refactor. Explain the shift from "subscribe broadly, then scan`state.matches` for the route you care about" to "subscribe directly to the relevant match store or route-derived store". The hero example is `useMatch`.
77
+
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.
91
78
92
-
**Points to cover:**
79
+
```ts
80
+
// Before
81
+
useRouterState({
82
+
select: (state) =>state.matches.find(/* route or match lookup */),
83
+
})
93
84
94
-
- Before: `useMatch` subscribed through the big router store, then searched `state.matches` to find the right match.
95
-
- After: `useMatch` resolves the relevant store first, then subscribes directly to it.
96
-
- For `opts.from`, `getMatchStoreByRouteId(routeId)` gives one cached computed store per route id.
97
-
- That computed store depends on `matchesId` plus the resolved match store, so unrelated match updates do not propagate through it.
98
-
- For nearest-match reads, the hook can subscribe directly to the active match store from context.
99
-
- The LRU cache is a practical detail worth mentioning because it shows this was engineered, not just conceptual.
useStore(matchStore, (match) =>/* select from one match */)
88
+
```
100
89
101
-
**Evidence to place here:**
102
-
103
-
- Store-update-count history graphs for React, Solid, and Vue
104
-
- These graphs belong here because they are the most direct evidence that narrower subscriptions remove unnecessary work
90
+
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.
105
91
106
92
<!-- ::start:tabs -->
107
93
@@ -119,53 +105,73 @@ A video showing that on stateful event in the core of the router, only specific
119
105
120
106
<!-- ::end:tabs -->
121
107
122
-
**Transition out:** "Once the model is granular, the store boundary gets a lot cleaner too."
108
+
These graphs are the most direct proof that change propagation got narrower.
123
109
124
-
### 6. Injectable Store Implementations
110
+
##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.
127
113
128
-
**Section goal:** Explain that the router core now defines a reactive contract instead of hardcoding one store engine. React and Vue plug in TanStack Store (now powered by alien-signals, thanks to @DavidKPiano). Solid plugs in native signals and memos. Same granular model, different store implementations.
114
+
The core now defines what a router store must do. Each adapter provides the implementation.
129
115
130
-
This is the section that makes the architecture feel unusually well-designed.
116
+
```ts
117
+
exportinterfaceRouterReadableStore<TValue> {
118
+
readonly state:TValue
119
+
}
131
120
132
-
**Transition out:** "All of this is invisible to users. The APIs didn't change."
**Section goal:** Translate the architectural work into user-visible consequences. Existing hooks and components (`useMatch`, `useLocation`, `Link`, match rendering) now subscribe more narrowly by default. No new API to learn. The router just does less unnecessary work during navigation and preload flows.
141
+
This keeps one router core while letting each adapter plug in the store model it wants.
139
142
140
-
**Points to cover:**
143
+
## Observable Result: Less Work During Navigation
141
144
142
-
- This refactor does not ask users to adopt a new API or a new mental model.
143
-
- The same public hooks now react with more locality because the underlying subscriptions are narrower.
144
-
- This is why the wins show up during navigation-heavy paths: links, match updates, pending transitions, route changes.
145
-
- Keep this section focused on observable behavior, not internal implementation details.
145
+
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.
146
146
147
-
**Evidence to place here:**
147
+
The `benchmarks/client-nav` benchmarks isolate client-side navigation cost on a synthetic rerender-heavy page.
148
148
149
-
-Client-side navigation benchmark history graphs for React, Solid, and Vue
150
-
-Present the benchmark as intentionally stressing rerender-heavy client-side navigation, not as a universal app benchmark
151
-
-Small bundle-size markdown table if it reads naturally here; otherwise keep it near the closing as a short tradeoff note
149
+
- React: `7ms -> 4.5ms`
150
+
- Solid: `12ms -> 8ms`
151
+
- Vue: `7.5ms -> 6ms`
152
152
153
153
<!-- ::start:tabs -->
154
154
155
155
#### React
156
156
157
-

157
+

158
158
159
159
#### Solid
160
160
161
-

161
+

162
162
163
163
#### Vue
164
164
165
-

165
+

166
166
167
167
<!-- ::end:tabs -->
168
168
169
+
There is also a bundle-size tradeoff. In our synthetic bundle-size benchmarks, measuring gzipped sizes:
170
+
171
+
- ↗ React increased by `~1KiB`
172
+
- ↗ Vue increased by `~1KiB`
173
+
- ↘ Solid decreased by `~1KiB`
174
+
169
175
<!-- ::start:tabs -->
170
176
171
177
#### React
@@ -182,60 +188,10 @@ This is the section that makes the architecture feel unusually well-designed.
182
188
183
189
<!-- ::end:tabs -->
184
190
185
-
**Transition out:** "The performance graphs are a consequence of the architecture, not the article's thesis."
186
-
187
-
### 8. Closing
188
-
189
-
**Section goal:** End on the conceptual takeaway, not just numbers. TanStack Router got faster because it models routing in a more granular way.
| Bundle-size delta table (markdown) | To write | Section 7 or 8 |
206
-
207
-
## Code Snippets
208
-
209
-
TBD — will discover what we need during drafting. Candidates if needed:
191
+
## Closing
210
192
211
-
- A before/after of what `useMatch` subscribes to internally
212
-
- The `getMatchStoreByRouteId` derived-store pattern
213
-
- The store-factory contract that adapters implement
214
-
215
-
## Writing Guardrails
216
-
217
-
- Keep `signals` as supporting vocabulary, not the headline story.
218
-
- Keep benchmarks as proof of the redesign, not the reason for it.
219
-
- Emphasize architectural locality over raw speed.
220
-
- Make the multi-framework angle feel like the natural payoff of a clean boundary.
221
-
- Mention alien-signals and credit @DavidKPiano, but keep it to 1–2 sentences.
222
-
- No SSR section (the optimization was not new for React; Solid/Vue just caught up).
223
-
- Every claim should be substantiated by code, by measurements/graphs, or by an external trustworthy source.
224
-
225
-
---
193
+
This refactor did not just add signals to the old model. It also changed the reactive model itself.
226
194
227
-
## Intro Draft
195
+
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.
228
196
229
-
TanStack Store recently migrated to [alien-signals](https://github.com/stackblitz/alien-signals) under the hood, replacing its previous reactive engine with a hyper-performant push-pull signal graph. This was great work by [@DavidKPiano](https://github.com/davidkpiano), and it gave us a faster reactive primitive to build on.
230
-
231
-
But faster primitives alone do not make a router faster. The more important question is what the router is _doing_ with its reactive state.
232
-
233
-
For a long time, TanStack Router centered its reactivity around one large object: `router.state`. It held location, active matches, pending matches, cached matches, route-level data, status flags, and more. That model was useful, and it helped us prototype and ship many of the APIs the router has today. But it also meant many consumers were still subscribing through the same broad reactive surface, even when they only cared about one route match or one small part of navigation state.
234
-
235
-
This refactor changes that internal shape.
236
-
237
-
It does not add a signals API, and it does not expose new primitives. What it does is replace one coarse reactive store with a graph of smaller, purpose-specific stores, and make the store implementation itself injectable so React and Vue can use TanStack Store while Solid can use native signals.
238
-
239
-
The result is a router that does less unnecessary work: significantly fewer store updates during navigation, substantially faster synthetic client-side navigation benchmarks, and a more granular internal model for routing state.
240
-
241
-
---
197
+
Routing is a graph. Now the reactivity is one too.
0 commit comments