Skip to content

Commit 81afc88

Browse files
committed
more fleshed out
1 parent a57c770 commit 81afc88

File tree

1 file changed

+90
-134
lines changed

1 file changed

+90
-134
lines changed
Lines changed: 90 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,43 @@
11
---
2-
published: 2026-03-01
2+
published: 2026-03-15
33
authors:
44
- Florian Pellet
5-
title: From One Big Router Store to a Granular Signal Graph
6-
# title: How TanStack Router Became Granularly Reactive
7-
# title: TanStack Router's Granular Reactivity Rewrite
8-
# title: Why TanStack Router No Longer Rerenders the World
9-
# title: Routing Is a Graph. Now Our Reactivity Is Too.
10-
# title: TanStack Router: From Monolithic State to Signal Graph
5+
# title: 'How TanStack Router Became Granularly Reactive'
6+
# title: 'TanStack Router''s Granular Reactivity Rewrite'
7+
# title: 'Routing Is a Graph. Now Our Reactivity Is Too.'
8+
title: 'From One Big Router Store to a Granular Signal Graph'
119
---
1210

1311
![veins of emerald as a signal graph embedded in the rock of a tropical island](/blog-assets/tanstack-router-signal-graph/header.png)
1412

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.
1614

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.
1816

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.
2018

21-
## Article Shape
19+
## Old Model: One Broad Router State
2220

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`.
2722

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.
2924

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 |
26+
| -------------------- | -------------------------------------------- | -------------------------------- |
27+
| Location | `location`, `resolvedLocation` | `useLocation`, `Link` |
28+
| 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 |
3131

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.
4733

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
4935

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.
5137

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.
5339

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.
40+
This is where the mismatch becomes visible:
5541

5642
<figure>
5743
<video src="/blog-assets/tanstack-router-signal-graph/before-router-state-blob.mp4" playsinline loop autoplay muted></video>
@@ -60,19 +46,20 @@ A video showing that on every stateful event in the core of the router, changes
6046
</figcaption>
6147
</figure>
6248

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.
6450

65-
### 4. The New Model
51+
## New Model: Split Router State into Stores
6652

67-
**Working title:** _A graph of stores_
53+
The new model breaks that broad surface into smaller stores with narrower responsibilities.
6854

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"
7059

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.
7261

73-
- Show the before animated diagram in section 2
74-
- Show the after animated diagram here
75-
- Brief callout to the before/after videos
62+
The new picture looks like this:
7663

7764
<figure>
7865
<video src="/blog-assets/tanstack-router-signal-graph/after-granular-store-graph.mp4" playsinline loop autoplay muted></video>
@@ -81,27 +68,26 @@ A video showing that on stateful event in the core of the router, only specific
8168
</figcaption>
8269
</figure>
8370

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.
8572

86-
### 5. Per-Match and Per-Route Reactivity
73+
## Hook-Level Change: Subscribe to the Relevant Store
8774

88-
**Working title:** _Subscribe to what you need_
75+
The clearest example is `useMatch`.
8976

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.
9178

92-
**Points to cover:**
79+
```ts
80+
// Before
81+
useRouterState({
82+
select: (state) => state.matches.find(/* route or match lookup */),
83+
})
9384

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.
85+
// After
86+
const matchStore = router.stores.getMatchStoreByRouteId(routeId)
87+
useStore(matchStore, (match) => /* select from one match */)
88+
```
10089

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.
10591

10692
<!-- ::start:tabs -->
10793

@@ -119,53 +105,73 @@ A video showing that on stateful event in the core of the router, only specific
119105

120106
<!-- ::end:tabs -->
121107

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.
123109

124-
### 6. Injectable Store Implementations
110+
## Store Boundary: One Contract, Multiple Implementations
125111

126-
**Working title:** _One contract, multiple implementations_
112+
The refactor did not only split router state into smaller stores. It also moved the store implementation behind a contract.
127113

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.
129115

130-
This is the section that makes the architecture feel unusually well-designed.
116+
```ts
117+
export interface RouterReadableStore<TValue> {
118+
readonly state: TValue
119+
}
131120

132-
**Transition out:** "All of this is invisible to users. The APIs didn't change."
121+
export interface RouterWritableStore<
122+
TValue,
123+
> extends RouterReadableStore<TValue> {
124+
setState: (updater: (prev: TValue) => TValue) => void
125+
}
133126

134-
### 7. What Users Actually Feel
127+
export type StoreConfig = {
128+
createMutableStore: MutableStoreFactory
129+
createReadonlyStore: ReadonlyStoreFactory
130+
batch: RouterBatchFn
131+
init?: (stores: RouterStores<AnyRoute>) => void
132+
}
133+
```
135134
136-
**Working title:** _Less work, same API_
135+
| Adapter | Store implementation |
136+
| :------ | :------------------- |
137+
| React | TanStack Store |
138+
| Vue | TanStack Store |
139+
| Solid | native signals |
137140
138-
**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.
139142
140-
**Points to cover:**
143+
## Observable Result: Less Work During Navigation
141144
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.
146146
147-
**Evidence to place here:**
147+
The `benchmarks/client-nav` benchmarks isolate client-side navigation cost on a synthetic rerender-heavy page.
148148
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`
152152
153153
<!-- ::start:tabs -->
154154
155155
#### React
156156
157-
![A graph showing the duration of 10 navigations on a synthetic tanstack/react-router app going from about 70ms to about 45ms](/blog-assets/tanstack-router-signal-graph/client-side-nav-react.png)
157+
![A graph showing the duration of a single navigation on a synthetic tanstack/react-router app going from about 7ms to about 4.5ms](/blog-assets/tanstack-router-signal-graph/client-side-nav-react.png)
158158
159159
#### Solid
160160
161-
![A graph showing the duration of 10 navigations on a synthetic tanstack/solid-router app going from about 120ms to about 80ms](/blog-assets/tanstack-router-signal-graph/client-side-nav-solid.png)
161+
![A graph showing the duration of a single navigation on a synthetic tanstack/solid-router app going from about 12ms to about 8ms](/blog-assets/tanstack-router-signal-graph/client-side-nav-solid.png)
162162
163163
#### Vue
164164
165-
![A graph showing the duration of 10 navigations on a synthetic tanstack/vue-router app going from about 75ms to about 60ms](/blog-assets/tanstack-router-signal-graph/client-side-nav-vue.png)
165+
![A graph showing the duration of a single navigation on a synthetic tanstack/vue-router app going from about 7.5ms to about 6ms](/blog-assets/tanstack-router-signal-graph/client-side-nav-vue.png)
166166
167167
<!-- ::end:tabs -->
168168
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+
169175
<!-- ::start:tabs -->
170176
171177
#### React
@@ -182,60 +188,10 @@ This is the section that makes the architecture feel unusually well-designed.
182188
183189
<!-- ::end:tabs -->
184190
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.
190-
191-
---
192-
193-
## Visuals Inventory
194-
195-
| Visual | Status | Placement |
196-
| ------------------------------------------ | -------- | -------------- |
197-
| Before/after animated diagram (before) | Done | Section 2 |
198-
| Before/after animated diagram (after) | Done | Section 4 |
199-
| Store-update-count history graph — React | Done | Section 5 |
200-
| Store-update-count history graph — Solid | Done | Section 5 |
201-
| Store-update-count history graph — Vue | Done | Section 5 |
202-
| Client-nav benchmark history graph — React | Done | Section 7 |
203-
| Client-nav benchmark history graph — Solid | Done | Section 7 |
204-
| Client-nav benchmark history graph — Vue | Done | Section 7 |
205-
| 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
210192
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.
226194
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.
228196
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

Comments
 (0)