Why Rich Harris reverted Svelte's $state.link 24 hours after merging it
The cheapest moment to walk back a shipped API is before anyone arrives — Svelte's two senior engineers spent twenty-four hours showing how the window closes.
On the evening of August 20, 2024, Rich Harris pressed merge on
a pull request
that had been open for thirty days. The PR — feat: adds $state.link rune — had been authored by Dominic Gannaway, who writes under the
handle trueadm and leads Svelte 5’s reactivity model. Rich-Harris,
the project’s founder, was the merger of record. They had agreed.
Three hours later Rich filed the revert.
Twenty-four hours and seventeen minutes after the merge, $state.link
was gone. The whole cycle — the add, the public disagreement, the
concession, the merge of the revert — happened over a single weekend
in two PR threads, between the two senior people responsible for
Svelte 5’s reactivity. No user wrote a production line that depended
on it. This is the case study.
What $state.link was meant to solve
By summer 2024, Svelte 5 was nearly done. The runeset — $state,
$derived, $effect, $props, and a handful of others — had been
set for months. Library authors and early adopters were shipping. The
shape of the API was largely settled.
There was a hole. A specific class of pattern — familiar to every framework that has solved it — kept resurfacing in user threads and review comments: the deep-reactive prop. A component takes a prop; the prop is a piece of state from above; the component wants to read that state, watch it change, and write to it locally, without asking the parent to re-render the whole subtree.
The two existing rune options each broke at this exact combination.
$effect was the obvious first try. An effect runs whenever its
dependencies change, so you could mirror the prop with one. But
effects run after render, which means a local mirror read between
mutations sees yesterday’s value. Effects also don’t run on the
server during SSR, so the client and server diverged on every page
that used them this way. trueadm later called effects in this
position glitchy, and the word matters: he was naming a failure
mode, not a stylistic preference.
$derived was the other option. A derived value is reactive and
read-only by design — the rune doesn’t expose .set() or any write
path. That’s the core invariant: a derived is a function of its
inputs, never a value in its own right. Useful for many things;
not useful when the user needs both directions.
$state.link was the proposal to fill the gap. It would link a piece
of local state to a derived expression — read like a derived, write
like a state. One primitive, two semantics. The PR was open thirty
days, gathered twenty-five issue comments and eight review comments,
and landed at +423 lines, −27 across twenty-nine files. On August 20,
2024 at 19:11 UTC, Rich Harris pressed merge.
- trueadm opens PR #12545 —
feat: adds $state.link rune. - Rich-Harris merges #12545. trueadm’s rune is now in
main. - Rich files PR #12942 —
breaking: remove $state.link callback(partial removal). - Rich files PR #12943 —
breaking: remove $state.link(full revert), 2h58m after the merge. - trueadm and Rich post their positions on the revert thread, twenty-one minutes apart.
- trueadm concedes the implementation has bugs and the cheapest path is to land the revert.
- Rich merges #12943. Twenty-four hours and seventeen minutes after the original merge,
$state.linkis gone. - Svelte 5.0 GA ships, without a deeply-reactive-derived rune.
- paoloricciuti opens PR #17308 —
feat: allow $derived($state()) for deep reactive deriveds. Body: “Dominic and I had this idea.”
The disagreement, in twenty-one minutes
At 21:56 UTC — forty-five minutes after the merge — Rich filed PR #12942, proposing only the removal of the rune’s second-argument callback. Thirteen minutes after that he filed PR #12943, the full revert. He had spent under three hours between agreeing to the merge and proposing to undo it.
The revert PR’s body opens with five words and a self-citation: “This reverts #12545, as a more invasive alternative to #12942.” A few sentences down, Rich addresses himself:
I think we should remove
$state.link, and I’m annoyed at myself for not reaching this conclusion earlier.
The annoyance is at his own approval, not at the author of the rune. trueadm’s first reply landed sixteen minutes after the revert PR opened.
@trueadm
View on GitHub →
Effects aren’t the solution here. They are glitchy so reading from them between mutations like you can deriveds doesn’t work. SSR mismatches happen too as the effect doesn’t run on the server. This rune is very much needed.
This is the technical case, restated for the revert thread. It isn’t an argument from preference; it’s a list of failure modes that already exist if a user tries to fake the pattern with effects. The last sentence is the strongest word trueadm uses anywhere in the weekend’s record: very much needed.
Rich replied twenty-one minutes later.
@Rich-Harris
View on GitHub →
No-one has been able to explain to me why it’s needed, short of an example in #12545 that was honestly a bit too complicated for me to follow. Give me concrete examples!
Two things are worth holding onto from that exchange. First, the disagreement is technical. trueadm names failure modes; Rich asks for examples that would force them to surface in code a user actually writes. Neither imputes motive to the other. Second, the disagreement is short. They post their positions, with their names attached, and the thread doesn’t escalate. Over the next twenty-four hours the strongest word trueadm uses is firmly believe. The strongest words Rich uses are frustratingly abstract — applied to the discussion, not to trueadm. That is the ceiling.
The concession the next day
The thread ran for another nineteen hours. Other contributors joined. trueadm posted a long bullet list of objections to fixing the pattern with effects — glitching, waterfalls, future-work constraints, SSR hydration — and ended the comment with a sentence that moved the question.
@trueadm
View on GitHub →
$state.linkhas some bugs right now with the second argument (my fault). I feel like we should land the PR that removes it and redesign a bug-free version that tackles the problems.
This is narrower than it reads at first. The “(my fault)” is
parenthetical — it covers the bugs in the second-argument
implementation, not the API concept. trueadm is still defending the
underlying need: he firmly believes mutations to effects are bad; he
wants a rune that fills the deeply-reactive-derived gap; he agrees
$state.link as shipped has bugs and the cheapest path forward is
to revert and redesign. The concession is on implementation timing,
not on whether such a primitive should exist.
That distinction matters. It is the difference between “trueadm admitted he was wrong about the API” — which the thread does not support — and “trueadm and Rich found a working agreement: revert this implementation now, revisit the concept later.” The second is what the public record actually says.
Seven hours after that comment, Rich pressed merge on #12943.
@Rich-Harris
View on GitHub →
I’m going to go ahead and merge this PR, because the longer the API remains shipped the more harm will come from people adding it to their codebases, and the less likely it is that we’ll be able to make improvements (such as renaming it) due to inertia bias.
Apologies for creating this situation.
Read the apology twice. “Apologies for creating this situation” is
to the community — to the people who would have added $state.link
to a codebase between merge and revert. The previous sentence is
about adoption inertia: the harm is that real users might write the
API into real applications, and undoing that becomes more expensive
every day. The merge of the revert was at 19:28 UTC on August 21,
twenty-four hours and seventeen minutes after the original merge.
What Svelte 5 shipped instead
When Svelte 5.0 went generally available on October 19, 2024 — two months after the revert — there was no replacement rune for the deeply-reactive-derived case. The hole was real, and the team shipped without filling it.
The official answer was a workaround. Authors who needed both
derived from a prop and writable internally were expected to
compose $derived.by with an inner $state reference. The pattern
works. It also requires the writer to understand what the absence of
$state.link means: there is no first-class primitive for this case,
and the language now expects you to compose two existing ones. For
users coming from the Svelte 4 store API the difference is small. For
users new to the framework, the difference is the specific kind of
difficulty that doesn’t show up in changelogs but does show up in the
issue tracker.
The first issue went up four months later. Then more.
Sixteen months of the same need
The first follow-up was issue #13452 in March 2025, titled simply
“Svelte 5: Bring back $state.link.” It was closed the same month,
marked completed, with maintainers pointing at the workaround. By
summer 2025 three more issues had landed — #14536 about not being
able to link reactivity from $props(), #15164 about object
ownership in $derived containing $state, and #11128 proposing “a
mutable $derived rune.” All four were closed. The maintainers’
position was consistent: the workaround exists, the runes don’t need
a new primitive, the pattern is already expressible.
In December 2025, paoloricciuti — a Svelte maintainer — opened
PR #17308. Its title
is feat: allow $derived($state()) for deep reactive deriveds. The
body opens with eight words: “This needs discussion, but Dominic and
I had this idea.” Dominic is trueadm. Sixteen months after the
revert, the engineer who wrote $state.link is in the long-tail
thread proposing the next attempt — under a different name, a
different syntax, addressing the same underlying need.
This is not $state.link returning. The new proposal is
$derived($state(value)) — composing two existing runes inside a
nested call to express deep reactivity in a derived. The shape is
different. The semantics target the same gap. As of May 2026 the PR
is open, has forty-two comments, and last saw activity in March. The
hole that $state.link was meant to fill is still being worked.
The 24-hour hinge
A shipped API is a one-way door. The handle on the inside is loose for about twenty-four hours.
The number isn’t a rule; it’s an estimate of the window during which a maintainer can revert a merged change without inheriting the full cost of a deprecation — the migration guide, the deprecation warnings, the major-version bump, the Stack Overflow threads three years later. Some of that cost is incurred the moment a release ships. Almost all of it is incurred the moment real codebases start importing the API. Between merge and adoption, the maintainer has a narrow window in which the correction is essentially free: some review time, a minor embarrassment, and a forty-eight-hour spread of public discussion. Beyond it, the price climbs every day.
Rich’s merge comment names this directly. “The longer the API remains shipped the more harm will come from people adding it to their codebases.” The hinge isn’t loose because the API is wrong. The hinge is loose because adoption hasn’t happened yet, and adoption is what makes deprecations expensive.
What $state.link did right was use the window. trueadm authored the
rune. Rich-Harris merged it. Rich-Harris filed and merged the revert.
trueadm agreed the revert was correct, while keeping his position on
the underlying problem. The disagreement that produced the revert was
technical, twenty-one minutes long at its hottest, collegial
throughout. The replacement work has continued for sixteen months,
with the same engineer involved in both attempts. None of that is
failure. The failure mode would have been Svelte 5 shipping with
$state.link because no one wanted to walk back a merged change
that the project’s two senior engineers had just agreed on.
The cheapest moment to walk back a shipped API is before anyone arrives. Two of the people best positioned to know this gave a public demonstration of why.
Further reading
- PR #12545 —
feat: adds $state.link rune(the original, +423/−27) - PR #12943 —
breaking: remove $state.link(the revert, +33/−315, merged 24h17m later) - PR #17308 —
feat: allow $derived($state()) for deep reactive deriveds(the long-tail attempt, open as of May 2026)