Skip to content

Commit 81c266a

Browse files
committed
cleanup
1 parent e99043f commit 81c266a

File tree

1 file changed

+81
-31
lines changed

1 file changed

+81
-31
lines changed

src/blog/tanstack-router-signal-graph.md

Lines changed: 81 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,15 @@ title: 'From One Big Router Store to a Granular Signal Graph'
1010

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

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

1515
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.
1616

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

1923
## Old Model: One Broad Router State
2024

@@ -27,7 +31,7 @@ That was useful. It made it possible to prototype features quickly and ship a br
2731
| Location | `location`, `resolvedLocation` | `useLocation`, `Link` |
2832
| Match lifecycle | `matches`, `pendingMatches`, `cachedMatches` | `useMatch`, `Matches`, `Outlet` |
2933
| 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 |
3135

3236
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.
3337

@@ -48,16 +52,17 @@ A video showing that on every stateful event in the core of the router, changes
4852

4953
The point is that `router.state` was broader than what many consumers actually needed.
5054

51-
## New Model: Split Router State into Stores
55+
## New Model: The Graph Becomes the Source of Truth
5256

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

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

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

6267
The new picture looks like this:
6368

@@ -69,14 +74,14 @@ A video showing that on stateful event in the core of the router, only specific
6974
</figure>
7075

7176
> [!NOTE]
72-
> Active, pending, and cached matches are now modeled differently because
77+
> Active, pending, and cached matches are now modeled separately because
7378
> they have different lifecycles. This reduces state propagation even further.
7479
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.
7681

7782
## Hook-Level Change: Subscribe to the Relevant Store
7883

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

8186
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.
8287

@@ -96,21 +101,36 @@ useStore(matchStore, (match) => /* select from one match */)
96101
> in a Least-Recently-Used cache so it can be reused by other subscribers
97102
> without leaking memory.
98103
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).
100105

101106
<!-- ::start:tabs -->
102107

103108
#### React
104109

105-
![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](/blog-assets/tanstack-router-signal-graph/store-updates-history-react.png)
110+
<figure>
111+
<img src="/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>
106116

107117
#### Solid
108118

109-
![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](/blog-assets/tanstack-router-signal-graph/store-updates-history-solid.png)
119+
<figure>
120+
<img src="/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>
110125

111126
#### Vue
112127

113-
![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](/blog-assets/tanstack-router-signal-graph/store-updates-history-vue.png)
128+
<figure>
129+
<img src="/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.
132+
</figcaption>
133+
</figure>
114134

115135
<!-- ::end:tabs -->
116136

@@ -127,9 +147,8 @@ export interface RouterReadableStore<TValue> {
127147
readonly state: TValue
128148
}
129149

130-
export interface RouterWritableStore<
131-
TValue,
132-
> extends RouterReadableStore<TValue> {
150+
export interface RouterWritableStore<TValue> {
151+
readonly state: TValue
133152
setState: (updater: (prev: TValue) => TValue) => void
134153
}
135154

@@ -145,19 +164,19 @@ export type StoreConfig = {
145164
| :------ | :------------------- |
146165
| React | TanStack Store |
147166
| Vue | TanStack Store |
148-
| Solid | native signals |
167+
| Solid | Solid signals |
149168
150169
This keeps one router core while letting each adapter plug in the store model it wants.
151170
152171
> [!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`
154173
> to dispose detached roots when those stores are garbage-collected.
155174
156175
## Observable Result: Less Work During Navigation
157176
158177
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.
159178
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.
161180
162181
- React: `7ms -> 4.5ms`
163182
- Solid: `12ms -> 8ms`
@@ -167,15 +186,31 @@ The `benchmarks/client-nav` benchmarks isolate client-side navigation cost on a
167186
168187
#### React
169188
170-
![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)
189+
<figure>
190+
<img src="/blog-assets/tanstack-router-signal-graph/client-side-nav-react.png" alt="">
191+
<figcaption>
192+
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>
171195
172196
#### Solid
173197
174-
![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)
198+
<figure>
199+
<img src="/blog-assets/tanstack-router-signal-graph/client-side-nav-solid.png" alt="">
200+
<figcaption>
201+
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>
175204
176205
#### Vue
177206
178-
![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)
207+
208+
<figure>
209+
<img src="/blog-assets/tanstack-router-signal-graph/client-side-nav-vue.png" alt="">
210+
<figcaption>
211+
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>
179214
180215
<!-- ::end:tabs -->
181216
@@ -189,22 +224,37 @@ There is also a bundle-size tradeoff. In our synthetic bundle-size benchmarks, m
189224
190225
#### React
191226
192-
![A graph the history of the bundle size of a synthetic tanstack/react-router app, gaining 1KiB gzipped with this latest change](/blog-assets/tanstack-router-signal-graph/bundle-size-history-react.png)
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>
193233
194234
#### Solid
195235
196-
![A graph the history of the bundle size of a synthetic tanstack/solid-router app, shedding 1KiB gzipped with this latest change](/blog-assets/tanstack-router-signal-graph/bundle-size-history-solid.png)
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>
197242
198243
#### Vue
199244
200-
![A graph the history of the bundle size of a synthetic tanstack/vue-router app, gaining 1KiB gzipped with this latest change](/blog-assets/tanstack-router-signal-graph/bundle-size-history-vue.png)
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>
201251
202252
<!-- ::end:tabs -->
203253
204254
## Closing
205255
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.
207257
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.
209259
210260
Routing is a graph. Now the reactivity is one too.

0 commit comments

Comments
 (0)