Làm Chủ Svelte 5 Runes: $state, $derived và $effect Thay Thế Store Truyền Thống

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Vấn Đề Với Store Mà Ít Ai Nói Đến

Bạn đã xây dựng một ứng dụng Svelte được vài tháng. Mọi thứ trôi chảy — cho đến khi cây component vượt quá ba cấp độ. Đột nhiên bạn phải truyền store qua props, import get() trong các file không phải component, và mò mẫm tìm hiểu tại sao một lần cập nhật writable() lại kích hoạt ba lần re-render không mong muốn ở một component con bạn gần như quên mất sự tồn tại của nó.

Đó chính xác là tình huống tôi gặp trong một dự án dashboard năm ngoái. Chúng tôi có userStore, settingsStore và notificationsStore, tất cả rối tung với các derived store phụ thuộc lẫn nhau. Logic thì hợp lý trên giấy tờ, nhưng đọc lại đoạn code đó sau sáu tuần cứ như đang làm khảo cổ học.

Svelte 5 Runes ra đời chính xác vì pattern này sụp đổ khi quy mô tăng lên. Và chúng không chỉ là syntax sugar — chúng thay thế toàn bộ mô hình reactivity.

Vấn Đề Thực Sự Của Store Là Gì

Svelte store không hỏng. Chúng vẫn hoạt động. Nhưng chúng mang theo những ma sát thực sự tích lũy dần khi ứng dụng phát triển:

  • Tiền tố subscription $ là cú pháp ma thuật — nó chỉ hoạt động bên trong file .svelte. Ngay khi bạn cần reactive state trong một file .ts thuần hoặc một class, bạn phải gọi get() và subscribe thủ công.
  • Derived store rườm rà — derived([storeA, storeB], ([a, b]) => a + b) về mặt chức năng thì được, nhưng quá lắm lời cho thứ gì đó về mặt khái niệm rất đơn giản.
  • Store là giá trị, không phải biến — bạn không thể viết thẳng userStore.name = 'Alice'. Bạn phải gọi update() hoặc set(), điều này phá vỡ mô hình tư duy trực quan.
  • Side effect không có chỗ ở gọn gàng — $: { someEffect() } trong câu lệnh reactive của Svelte 4 chạy mỗi khi có thay đổi nhưng không có cơ chế teardown tích hợp sẵn.

Từng cái một thì chưa đến mức chết người. Nhưng gộp lại, ma sát tích lũy dần — và cuối cùng bạn tốn nhiều thời gian quản lý boilerplate của store hơn là thực sự xây dựng tính năng.

Svelte 5 Runes: Một Nền Tảng Khác

Rune là các chỉ thị cho compiler. Svelte compiler chuyển đổi chúng lúc build — và khác với tiền tố store $, chúng hoạt động ở bất cứ đâu JavaScript chạy: file .svelte, module .svelte.ts, và cả file .ts thuần.

Ba rune bạn sẽ dùng nhiều nhất là $state, $derived và $effect.

$state: Biến Reactive Mà Cảm Giác Như Biến Thường

Thay vì tạo một writable store rồi subscribe vào nó, bạn khai báo reactive state trực tiếp:

// Svelte 4 — cách dùng store
import { writable } from 'svelte/store';
const count = writable(0);
// Trong component: $count để đọc, count.set(1) hoặc count.update(n => n + 1) để ghi

// Svelte 5 — cách dùng rune
let count = $state(0);
// Đọc: count — Ghi: count = 1 hoặc count++
// Chỉ vậy thôi.

Cú pháp gán trực tiếp cảm thấy tự nhiên vì nó thực sự tự nhiên. Compiler của Svelte chặn các lệnh gán vào biến $state và tự động kích hoạt reactivity. Không cần .set(), không cần .update().

Object và array mặc định đã có deep reactivity:

let user = $state({ name: 'Alice', age: 30 });

// Dòng này kích hoạt reactivity — không cần spread hay gán lại toàn bộ object
user.age = 31;
user.name = 'Bob';

So sánh với Svelte 4, đôi khi bạn phải gán lại toàn bộ object chỉ để reactive update kích hoạt một cách đáng tin cậy.

$derived: Giá Trị Computed Không Lắm Lời

Derived store trong Svelte 4 nghĩa là phải import derived rồi bọc mọi thứ trong một callback. Với Runes, computed state đọc như bất kỳ biểu thức nào khác:

// Svelte 4
import { derived } from 'svelte/store';
const fullName = derived(
  [firstName, lastName],
  ([$first, $last]) => `${$first} ${$last}`
);

// Svelte 5
let firstName = $state('Alice');
let lastName = $state('Smith');
let fullName = $derived(`${firstName} ${lastName}`);

Svelte theo dõi các biến $state mà biểu thức derived của bạn đọc, rồi chỉ chạy lại khi những biến đó thay đổi. Không cần mảng dependency thủ công. Không cần gọi get().

Với logic derived phức tạp hơn, dùng $derived.by():

let cart = $state([{ price: 20 }, { price: 35 }, { price: 15 }]);

let total = $derived.by(() => {
  return cart.reduce((sum, item) => sum + item.price, 0);
});

$effect: Side Effect Có Cơ Chế Teardown

Đây là thứ đã dọn dẹp những đoạn code lộn xộn nhất trong các dự án của tôi. Các block reactive $: { ... } của Svelte 4 chạy side effect nhưng không có cơ chế cleanup. Nếu bạn thiết lập event listener hay WebSocket bên trong đó, bạn cần các lệnh gọi onDestroy riêng biệt cộng với việc điều phối thủ công cẩn thận để teardown chúng.

// Svelte 5
let userId = $state(null);

$effect(() => {
  if (!userId) return;
  
  const socket = new WebSocket(`wss://api.example.com/user/${userId}`);
  socket.onmessage = (e) => handleMessage(e.data);
  
  // Trả về hàm cleanup — chạy trước khi effect chạy lại hoặc khi destroy
  return () => {
    socket.close();
  };
});

Khi userId thay đổi, Svelte chạy cleanup trước, rồi mới chạy lại effect. Không cần xoay sở với onDestroy. Vòng đời nằm ngay bên trong effect — đúng chỗ nó nên ở.

Chia Sẻ State Giữa Các Component: Runes Thay Thế Global Store

Đây có lẽ là pattern hữu ích nhất cần nắm vững khi chuyển khỏi store. Global writable là câu trả lời tiêu chuẩn để chia sẻ state giữa các component không liên quan đến nhau. Với Runes, bạn tạo shared reactive state trong file .svelte.ts:

// state/user.svelte.ts
let _user = $state(null);
let _loading = $state(false);

export function getUser() {
  return _user;
}

export async function fetchUser(id) {
  _loading = true;
  try {
    _user = await api.getUser(id);
  } finally {
    _loading = false;
  }
}

export function isLoading() {
  return _loading;
}
// Trong bất kỳ component nào
import { getUser, fetchUser, isLoading } from './state/user.svelte.ts';

let user = $derived(getUser());
let loading = $derived(isLoading());

Có một điều cần ghi nhớ: $state trong một module là shared state. Một instance duy nhất cho mỗi lần import — giống như bất kỳ biến cấp module nào. Mọi component đọc nó qua $derived sẽ re-render khi nó thay đổi.

Trên thực tế, pattern này — $state cấp module với các hàm getter/setter được export — có lẽ là thứ đầu tiên cần làm quen khi chuyển sang Svelte 5. Bạn có được khả năng kiểm thử của plain function, reactivity của store, và khả năng đọc hiểu của biến thông thường.

Migration Thực Tế: Svelte 4 Store → Svelte 5 Runes

Đang migrate một ứng dụng hiện có? Việc ánh xạ rất đơn giản:

  • writable(initialValue) → $state(initialValue)
  • Tiền tố subscription $store → xóa đi, dùng thẳng biến
  • store.set(value) → gán trực tiếp: value = newValue
  • derived([a, b], fn) → $derived(expression)
  • $: { sideEffect() } → $effect(() => { sideEffect() })
  • Global writable store → $state cấp module trong file .svelte.ts

Readable store ít phổ biến hơn. Chúng ánh xạ thành $state với chỉ getter được export — không có setter trong public API.

Khi Nào Store Vẫn Còn Phù Hợp

Runes không khai tử store trong mọi tình huống. Các thư viện nhắm đến cả Svelte 4 và 5 nên dùng store — đó là API tương thích chéo. Tương tự với các tích hợp bên thứ ba được xây dựng xung quanh store contract (một số thư viện animation chẳng hạn).

Nhưng với state ở tầng ứng dụng trong codebase Svelte 5? Runes là con đường sạch sẽ hơn trong hầu hết mọi tình huống tôi đã gặp.

Sự Thay Đổi Trong Mô Hình Tư Duy

Điều chỉnh lớn nhất không phải là cú pháp. Mà là phá bỏ thói quen tìm đến store mỗi khi cần reactive state. Store cảm giác như reactive primitive “chính thức” vì trong nhiều năm, chúng đúng là như vậy.

Với Svelte 5, reactive primitive chỉ là một biến — được khai báo với $state. Khi điều đó thấm vào, phần còn lại tự nhiên sẽ rõ ràng: giá trị computed là biểu thức ($derived), side effect là các hàm có cleanup ($effect), và shared state là một module với các biến reactive.

Runes thực sự gần với JavaScript thuần hơn so với store. Sau khi migrate một vài dự án, tôi cứ chờ đợi gặp phải edge case nào đó buộc tôi quay lại với writable() — nhưng điều đó chưa xảy ra.

Share: