Signals & Fine-Grained Reactivity — The Model Taking Over Frontend (2026)
Bilingual deep-dive: signals vs Virtual DOM, the three primitives, a ~30-line vanilla runtime, push/pull glitch-free updates, Solid/Angular/Svelte/Vue/Preact/TC39, React compiler angle, pitfalls.
Tại sao điều này quan trọng năm 2026
Trong một thập kỷ, hầu hết thư viện UI trả lời cùng một câu hỏi: khi state đổi, chạy lại component và diff cây DOM. Mô hình đó nuôi React, và vẫn dùng được. Nhưng năm 2026, signals và fine-grained reactivity không còn là kiến thức ngách của SolidJS — chúng là mô hình mặc định trong Angular signals, Svelte 5 runes, Vue đang tiến hóa, Preact signals, và đề xuất TC39 đang active.
Nếu bạn chỉ hiểu useState + re-render, bạn sẽ đọc sai tài liệu hiện đại, thảo luận perf, và framework hướng compiler. Bài này cho bạn mô hình đồ thị phụ thuộc, implementation tối giản, và bản đồ đa framework.
Vấn đề cốt lõi: Re-render thô
Virtual DOM diff theo cả subtree
Trong kiểu render React, state nằm trong component; khi state đổi, React lên lịch re-render component đó (và thường cả con). Runtime diff cây ảo rồi vá DOM thật. Dù có memo và useMemo, đường mặc định vẫn là: hàm chạy lại → JSX tạo lại → diff quyết định gì đổi.
Đó là coarse-grained: đơn vị công việc thường là cả component (hoặc ranh giới memo), không phải từng node DOM hay text node.
State change in <App>
│
▼
Re-run App() ──► new VDOM subtree
│
▼
Diff vs previous
│
▼
Patch DOM (maybe skip some children via memo)
Với list lớn, chart lồng nhau, hoặc editor hàng nghìn node, chạy lại và diff có thể tốn hơn thay đổi DOM thực sự. Bạn tối ưu bằng tách component, virtualization, memo — nhưng abstraction mặc định vẫn xoay quanh lượt render.
Fine-grained: chỉ cập nhật thứ phụ thuộc signal
Signals đảo mặc định: state là ô reactive; consumer đăng ký; khi ô đổi, chỉ side effect đã subscribe chạy. Trong SolidJS và hệ tương tự, thường cập nhật DOM gắn trực tiếp effect tạo node — không re-execute cả component, không diff cả cây cho lần cập nhật đó.
count.set(4)
│
▼
Notify subscribers of `count` only
│
├──► effect: text node "4" (runs)
├──► computed: double → 8 (recomputes if needed)
└──► unrelated effect (does NOT run)
Đó là fine-grained reactivity: độ chính xác ở mức phụ thuộc, không phải ranh giới component.
Ba primitive
Mọi hệ signals nghiêm túc hội tụ về bộ ba giống nhau:
| Primitive | Role | Typical names |
|---|---|---|
| Signal | Mutable reactive source of truth | signal, $state, ref, Signal.State |
| Computed | Derived value; memoized from dependencies | computed, $derived, createMemo, Signal.Computed |
| Effect | Side effect that re-runs when deps change | effect, $effect, createEffect, watchEffect |
Signal — state ghi được
Signal giữ giá trị và tập subscriber (effect và computed đã đọc nó lần trước). Đọc signal đăng ký observer hiện tại; ghi báo subscriber.
Computed — dẫn xuất, cache, lazy
Computed đọc signal/computed khác, cache kết quả, chỉ tính lại khi dependency đổi. Nhiều lần đọc giữa các lần đổi trả cache (lazy + memo).
Effect — side effect, không phải dẫn xuất thuần
Effect chạy hàm vì side effect (DOM, log, network…) và chạy lại khi signal đọc trong lần chạy trước đổi. Effect không nên là lớp “business logic” mặc định — ưu tiên computed cho dữ liệu dẫn xuất.
Mô hình đồ thị phụ thuộc
┌─────────────┐
│ signal │
│ count │
└──────┬──────┘
│ read
┌───────────┼───────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ computed │ │ computed │ │ effect │
│ double │ │ isEven │ │ set text │
└────┬─────┘ └──────────┘ └──────────┘
│ read
▼
┌──────────┐
│ effect │
│ log 2x │
└──────────┘
Automatic tracking nghĩa là bạn không khai báo tay deps: [count] — runtime ghi signal nào được đọc khi effect/computed đang active. Đó là phép màu và cũng là cạm bẫy.
Runtime Vanilla JS tối giản (~30 dòng)
Đoạn dưới để học, không phải production — nhưng khớp cách engine thật tổ chức observer hiện tại, tập dependency, và danh sách subscriber.
let active = null;
function createSignal(initial) {
let value = initial;
const subs = new Set();
const read = () => {
if (active) subs.add(active);
return value;
};
const write = (next) => {
if (Object.is(value, next)) return;
value = next;
for (const sub of subs) sub.run();
};
return [read, write];
}
function createComputed(fn) {
const subs = new Set();
let value;
let dirty = true;
const comp = {
run() {
const prev = active;
active = comp;
if (dirty) {
value = fn();
dirty = false;
}
active = prev;
return value;
},
};
createEffect(() => {
fn();
dirty = true;
for (const sub of subs) sub.run();
});
return () => {
if (active) subs.add(active);
return comp.run();
};
}
function createEffect(fn) {
const effect = {
run() {
const prev = active;
active = effect;
fn();
active = prev;
},
};
effect.run();
return effect;
}
Cách đọc:
activelà observer hiện tại khi hàm reactive chạy.- Khi
read(), nếu cóactive, signal thêm vàosubs. - Khi
write(), chỉ subscriber chạy lại — không phải cả app. - Implementation thật còn xóa dependency cũ mỗi lần chạy (bỏ ở trên cho gọn).
const [count, setCount] = createSignal(0);
const double = createComputed(() => count() * 2);
createEffect(() => {
console.log('count', count(), 'double', double());
});
setCount(1); // logs once
setCount(1); // Object.is → no-op
setCount(2); // logs again
Push, pull, lazy, và cập nhật không glitch
Push vs pull
- Push: ghi signal báo subscriber ngay (thông báo eager).
- Pull: computed đọc giá trị mới khi có thứ gì đó hỏi (thường đánh dấu dirty trước).
Hầu hết framework kết hợp: push cờ dirty, pull khi đọc, tính lại lazy. Tránh tính lại nhánh graph không dùng.
Không glitch
Glitch là khi observer thoáng thấy trạng thái trung gian không nhất quán — vd a = 1; b = a + 1 và effect đọc b trước khi a propagate xong. Engine kiểu Solid thường sắp thứ tự topo hoặc cập nhật hai pha để effect chạy sau khi graph ổn định. Bạn hiếm khi nghĩ đến cho đến khi tự build runtime — lúc đó rất quan trọng.
So sánh framework và đề xuất
| System | Writable | Derived | Side effect | Notes |
|---|---|---|---|---|
| SolidJS | createSignal | createMemo | createEffect | JSX compiles to direct DOM bindings + effects |
| Angular | signal() | computed() | effect() | Zoneless-friendly; integrates with change detection |
| Svelte 5 | $state | $derived | $effect | Runes are compile-time tracked |
| Vue 3 | ref() / reactive() | computed() | watchEffect() | Still component-render centric; refs are reactive cells |
| Preact | signal() | computed() | effect() | @preact/signals integrates with VDOM via subscriptions |
| TC39 proposal | Signal.State | Signal.Computed | (user effects via patterns) | Standard library shape; still Stage 1-ish |
Tên khác; graph giống nhau.
Code qua các hệ sinh thái
SolidJS
import { createSignal, createMemo, createEffect } from 'solid-js';
function Counter() {
const [count, setCount] = createSignal(0);
const double = createMemo(() => count() * 2);
createEffect(() => {
console.log(count(), double());
});
return (
<button onClick={() => setCount((c) => c + 1)}>
{count()} × 2 = {double()}
</button>
);
}
JSX Solid không chạy lại cả hàm mỗi click như React — cập nhật fine-grained nhắm text và handler gắn lúc tạo.
Angular signals
import { Component, computed, effect, signal } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<button (click)="count.set(count() + 1)">
{{ count() }} × 2 = {{ double() }}
</button>
`,
})
export class CounterComponent {
count = signal(0);
double = computed(() => this.count() * 2);
constructor() {
effect(() => {
console.log(this.count(), this.double());
});
}
}
Svelte 5 runes
<script>
let count = $state(0);
let double = $derived(count * 2);
$effect(() => {
console.log(count, double);
});
</script>
<button onclick={() => count++}>
{count} × 2 = {double}
</button>
Compiler Svelte ghi nhận đọc/ghi nên reactivity deterministic, không cần proxy runtime rối.
Vue ref (ô reactive + render)
import { ref, computed, watchEffect } from 'vue';
export function useCounter() {
const count = ref(0);
const double = computed(() => count.value * 2);
watchEffect(() => {
console.log(count.value, double.value);
});
return { count, double };
}
ref Vue là ô kiểu signal, nhưng component vẫn re-render khi deps template đổi trừ khi bạn dùng tối ưu compile Vue 3.5+.
Preact signals
import { signal, computed, effect } from '@preact/signals';
const count = signal(0);
const double = computed(() => count.value * 2);
effect(() => {
console.log(count.value, double.value);
});
function Counter() {
return (
<button onClick={() => count.value++}>
{count} × 2 = {double}
</button>
);
}
Preact tích hợp signal với component VDOM bằng cách theo dõi component nào đọc signal nào — đường lai.
Đề xuất TC39 Signals (Stage 1)
import { Signal } from 'signal-polyfill'; // conceptual; API from proposal
const count = Signal.State(0);
const double = Signal.Computed(() => count.get() * 2);
// Consumers would use framework glue or manual subscribe APIs
console.log(double.get());
count.set(1);
console.log(double.get());
Đề xuất chuẩn hóa ô reactive tương tác được giữa thư viện — không phải full UI framework. Framework có thể bọc Signal.State bằng ergonomics ((), .value, runes).
Signals vs mô hình React
React giữ lượt render tường minh: useState kích re-render function component, rồi reconciliation vá DOM. Team React chưa đưa signals first-class vào core vì nhiều lý do lẫn nhau:
- Ổn định API và mô hình — triệu app nghĩ “render = f(state)”.
- Concurrent — scheduling, transition, Suspense xây quanh công việc render, không phải graph phụ thuộc.
- Interop — signals mạnh khi cả cây tham gia; ecosystem React gắn chặt render.
Góc React Compiler (ngắn, chính xác)
React Compiler phân tích component để tự memo và giảm re-render không cần signals. Đó là tối ưu compile-time của mô hình hiện tại, không phải chuyển sang gắn DOM fine-grained. Compiler + useMemo/memo kỷ luật thu hẹp khoảng perf cho nhiều app — nhưng abstraction vẫn render-first.
// Still: state change → schedule re-render → reconcile
function Row({ item }) {
const [n, setN] = useState(0);
return <button onClick={() => setN((v) => v + 1)}>{item.label} {n}</button>;
}
Với thư viện mới, signals là câu trả lời native; với React, cược là compile tốt hơn + API giữ nguyên.
Pitfall dev senior hay gặp
Effect chạy quá nhiều
Đặt logic dẫn xuất trong effect thay vì computed gây chạy thừa và nhấp nháy. Quy tắc: tính dữ liệu bằng computed; effect cho I/O và DOM.
Đọc không được track
Đọc signal ngoài effect/computed active — hoặc sau await không bọc effect — mất tracking. Trong Solid, untrack() tường minh; framework khác, destructuring có thể phá reactivity.
// ❌ Untracked: read happens once at setup
const v = count();
createEffect(() => {
console.log(v); // stale
});
// ✅ Read inside the effect
createEffect(() => {
console.log(count());
});
Stale closure trong async
createEffect(() => {
const id = userId();
fetch(`/api/${id}`).then((data) => {
// userId() is NOT re-tracked here
renderProfile(data, userId()); // may be stale if userId changed
});
});
Pattern: đọc lại signal tại chỗ dùng, hoặc helper async effect của framework.
Khi KHÔNG nên dùng signal
- State form tạm không chia sẻ cross-component — biến local hoặc input controlled có thể đủ.
- Khởi tạo một lần — đừng bọc hằng số trong signal.
- Cache data server — dùng lớp query/cache; signal không thay semantics cache HTTP.
- Thay state global khắp nơi — signal giải reactivity, không phải kiến trúc.
Khi nào dùng — Tóm tắt mô hình
Hỏi ba câu trước khi áp signal vào codebase:
- Chi phí ở reactive hay render? Cây nặng CPU → fine-grained thắng; cập nhật hiếm → mô hình nào cũng được.
- State fine-grained và chia sẻ nhiều lá? Signal mạnh khi nhiều DOM phụ thuộc cùng ô.
- Framework đã compile reactivity? Svelte/Angular/Solid — theo; React — cân nhắc Compiler trước.
Một câu nhớ: Signal là ô reactive; computed dẫn xuất; effect đồng bộ thế giới với các ô — và chỉ subscriber của các ô đó trả giá.
Bảng tra nhanh
| Task | Solid | Angular | Svelte 5 | Vue | Preact | TC39 |
|---|---|---|---|---|---|---|
| Create state | createSignal(0) | signal(0) | $state(0) | ref(0) | signal(0) | Signal.State(0) |
| Read | count() | count() | count | count.value | count.value | count.get() |
| Write | setCount(1) | count.set(1) | count = 1 | count.value = 1 | count.value = 1 | count.set(1) |
| Derive | createMemo(() => …) | computed(() => …) | $derived(…) | computed(() => …) | computed(() => …) | Signal.Computed(() => …) |
| Side effect | createEffect(() => …) | effect(() => …) | $effect(() => …) | watchEffect(() => …) | effect(() => …) | (app-specific) |
| Opt out of track | untrack(() => …) | untracked(() => …) | — | — | — | — |
Đọc thêm
- — mô hình “lazy evaluation” khác đáng so sánh.
- — đòn bẩy perf khác: bỏ qua công việc trước khi chạm UI.
- Đề xuất TC39: Signals trên GitHub.
Tóm tắt
Framework Virtual DOM re-render và diff mặc định — đơn vị công việc thô. Signals tách state thành ô được track với computed dẫn xuất và effect đồng bộ, chỉ cập nhật thứ phụ thuộc thay đổi. Runtime vanilla nhỏ cho thấy tracking phụ thuộc tự động có thể implement vài chục dòng. Năm 2026, Solid, Angular, Svelte 5, Vue, Preact, TC39 cùng hướng; React giữ render-first nhưng chống lãng phí bằng Compiler. Biết bạn giải bài toán gì — độ chính xác reactivity, không phải phép màu — rồi chọn đúng công cụ.