You saw “TC39 Signals” on Hacker News again this week. Your useState is fine. Your Zustand store is not deprecated. So why does this keep coming up?
Because the proposal is advancing, the same primitive already ships in Solid, Svelte 5 runes, and Angular, and a polyfill lets you run native javascript signals in your browser today. This piece is a code review of the TC39 signals proposal — what it actually is, what changes for you, and three patterns to paste into a project tonight. No hype reel.
What TC39 Signals Actually Are
The proposal was introduced in March 2024 by champions from Angular, Ember, Preact, Solid, Svelte, and Vue. Six framework camps, one spec — that alone tells you the design has been argued into the ground before reaching TC39. It’s still working its way through the process, but the API surface has settled enough that a polyfill exists.
Three primitives. That’s the whole user-facing surface.
Signal.State— a value that changes. Read it with.get(), write it with.set().Signal.Computed— a value derived from other signals. You give it a function; it caches the result and invalidates when its inputs change.Signal.Effect— a side effect that re-runs when the signals it reads change. (Mostly. We’ll get to the asterisk.)
The point of all three is auto-tracking. The signals a Computed or Effect reads register themselves at read time. No dependency arrays. No [count, doubled]. The reactive graph builds itself as your code runs.
That’s the language-feature part: native javascript signals are reactive primitives baked into the runtime, not a userland library bolted onto closures. The same pattern Solid, Svelte 5, and Angular already ship — promoted from framework convention to language standard.
The polyfill at github.com/proposal-signals/signal-polyfill implements that exact API today. Which means the comparison ahead isn’t theoretical.
The Rosetta Stone: One Counter, Three Reactivity Models
Forget benchmarks. The shortest way to feel the difference is to write the same boring thing three ways: a counter, a doubled value derived from it, and a log that fires when the counter changes.
React.
const [count, setCount] = useState(0);
const doubled = useMemo(() => count * 2, [count]);
useEffect(() => {
console.log(count);
}, [count]);
Two dependency arrays. You wrote count four times — once to declare, once to use, twice to remind React you used it. The linter shouts when you forget. That’s the tax: you’re the framework’s bookkeeper.
Solid.
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
createEffect(() => console.log(count()));
No dep array. Solid reads what you read — when count() runs inside createMemo, it registers the dependency on the way through.
Native TC39 Signal (via the polyfill).
import { Signal } from 'signal-polyfill';
const count = new Signal.State(0);
const doubled = new Signal.Computed(() => count.get() * 2);
// (Effect is lower-level than this — see below.)
Same shape as Solid. Different surface — new Signal.State(0) instead of createSignal, .get() instead of calling. But the reactivity model is identical. Solid devs have been writing TC39 code for years; the proposal is mostly Solid winning the spec.
One honest caveat: Signal.Effect in the proposal isn’t a one-liner. The native primitive is a watcher (Signal.subtle.Watcher) you wire to a Computed, and you decide when to flush. The proposal deliberately pushes scheduling decisions to the framework or app layer — that’s design, not omission. If you’ve ever fought useEffect’s timing semantics, you’ll understand why the spec wants the framework to own that, not the language.
The takeaway is plain: the proposal is the Solid/Svelte/Angular model, sanded down to a primitive the language can ship. The dependency-array tax isn’t getting fixed inside React. It’s getting routed around one layer below.
Three Patterns to Try With the Signal Polyfill Today
Enough comparison. Install the polyfill, paste, run.
npm install signal-polyfill
import { Signal } from 'signal-polyfill';
The polyfill implements the exact proposed surface, so anything you write today is forward-compatible.
Pattern 1: A Reactive Counter
const count = new Signal.State(0);
const isEven = new Signal.Computed(() => count.get() % 2 === 0);
count.set(1);
console.log(isEven.get()); // false
count.set(4);
console.log(isEven.get()); // true
Reading count.get() inside the Signal.Computed function registers the dependency. You never told isEven what it depends on — it figured that out by running. This is where “no dep array” earns its keep.
Pattern 2: A Derived State Chain
const items = new Signal.State([
{ name: 'shoes', price: 80 },
{ name: 'socks', price: 12 },
]);
const subtotal = new Signal.Computed(() =>
items.get().reduce((s, i) => s + i.price, 0)
);
const totalWithTax = new Signal.Computed(() => subtotal.get() * 1.08);
console.log(totalWithTax.get()); // 99.36
items.set([...items.get(), { name: 'laces', price: 5 }]);
console.log(totalWithTax.get()); // 104.76
Three signals, two of them derived. Update items once and both subtotal and totalWithTax invalidate. Computeds are pull-based — they only run when something asks for .get(). If nobody reads totalWithTax, the multiplication never happens. No wasted work, no manual memoization, no useCallback to remember.
Pattern 3: An Effect Without a Dependency Array
const name = new Signal.State('world');
const greeting = new Signal.Computed(() => `Hello, ${name.get()}`);
let pending = false;
const watcher = new Signal.subtle.Watcher(() => {
if (pending) return;
pending = true;
queueMicrotask(() => {
pending = false;
for (const s of watcher.getPending()) s.get();
console.log(greeting.get());
watcher.watch();
});
});
watcher.watch(greeting);
greeting.get(); // prime the graph
name.set('signals'); // logs: Hello, signals
name.set('Nico'); // logs: Hello, Nico
More ceremony than createEffect. That’s the point — the proposal exposes the mechanism (a watcher that fires when its tracked signals change) and lets the framework decide the policy (when to flush, how to batch, where to schedule). Most app code will never touch Signal.subtle.Watcher directly. Your framework will wrap it.
But you should write it once, by hand, so you understand what your framework is doing for you. Drop these three patterns into a Vite + vanilla JS project. They run today, no transpilation. Thirty minutes from npm install to “I get it.”
What This Actually Means For Your Framework
You ran the patterns. Now: does this change Monday?
React. No. The React team has been public about preferring the React Compiler over signals as a first-class pattern — auto-memoization that keeps the existing programming model. You might see a future library implement state on top of Signal.State once it ships natively, but useState is going nowhere. Don’t refactor. If you’re chasing the performance promise that signals offer, the React Compiler is your path, not Signal.State. And if you’re still picking a state management library, this proposal doesn’t change that answer.
Solid. You’ve been writing the proposal for years. When native signals ship, Solid can keep its current API as a thin wrapper or shim straight to the primitive. Your code is the most future-proof in the room.
Vue. ref and computed map onto the proposal cleanly. Vue’s reactivity layer will likely sit on top of native signals once available — small bundle wins, no rewrites required of you.
Svelte 5. Runes are signals with different syntax. $state, $derived, $effect — same model, compiler-flavored. You’re already there.
Angular. Signals are the recommended state path from 16+, with Zone.js being phased out in new code. The proposal validates the direction the framework already committed to.
The verdict in one line: if you ship React, native signals are a 2027+ concern — don’t touch your codebase. If you ship anything else from the current framework landscape, you’re already living in the future, and the proposal is the spec catching up to your stack.
Which leaves the only question that matters: what do you do tonight?
The Bottom Line
Your useState is still fine. Your Zustand store is still fine. The proposal is real, the polyfill works, and the pattern has already won at the framework layer — TC39 is documenting reality, not inventing it.
Three buckets, three answers. New side project this weekend? Install signal-polyfill, paste the three patterns above, ship a hundred-line demo. Production React app? Ignore for now — follow the React Compiler instead. Production Solid, Svelte, Angular, or Vue? You’re already there. Read the proposal so you can nod knowingly in the next architecture meeting.
The fastest way to have an informed opinion on javascript signals is to type the code once. Clone the polyfill repo, paste the patterns, take thirty minutes. Predicting which TC39 proposal ships and when is a fool’s game. Predicting that you’ll understand it better after writing forty lines of code is not.