ข้ามไปยังเนื้อหา

The $state Rune

$state สร้างค่า reactive อ่านมันเหมือนตัวแปรธรรมดาและ reassign เหมือนตัวแปรธรรมดา — compiler ทำให้ทุกการอ่านเป็น dependency และทุกการเขียนเป็น trigger

<script>
let count = $state(0);
</script>
<button onclick={() => count++}>{count}</button>

ไม่มี setter ไม่มีการเรียก update พิเศษใด ๆ count++ คือการ reassign จริง ๆ และเพราะ compiler track ว่าข้อความในปุ่มอ่าน count text node ตัวนั้นตัวเดียวจึง update

กด คอมไพล์ ด้านล่างเพื่อดูของจริง — counter ตัวเดียวกันนี้ผ่าน Svelte compiler จริงในเบราว์เซอร์ของคุณ สังเกต $.state(0) (สิ่งที่ count กลายเป็น) และ $.set(count, ...) (สิ่งที่ count++ คอมไพล์ออกมา) ในแผงผลลัพธ์

<script>
// English comments: keep this pinned to Svelte 5 runes mode.
let count = $state(0);
</script>
<button onclick={() => count++}>{count}</button>

เมื่อคุณส่ง object หรือ array เข้า $state Svelte ห่อมันด้วย proxy เพื่อให้การ mutateไม่ใช่แค่การ reassign เป็น reactive ด้วย การ push เข้า array หรือ set property ที่ซ้อนอยู่ข้างในจะ trigger update

<script>
let todos = $state([{ text: 'learn runes', done: false }]);
function toggle(todo) {
todo.done = !todo.done; // mutating a nested property is reactive
}
function add(text) {
todos.push({ text, done: false }); // mutating the array is reactive
}
</script>

นี่คือความต่างจริงจาก React ที่คุณต้องสร้าง object/array ใหม่เสมอ ใน Svelte todos.push(...) และ todo.done = true ใช้งานได้เลย — proxy สังเกตเห็นการ mutate reactivity เป็นแบบ deep: object และ array ที่ซ้อนอยู่ข้างในก็ถูก proxy เช่นกัน

flowchart LR
  write["mutate: todo.done = true"] --> proxy["$state proxy notices the change"]
  proxy --> update["update exactly the dependent DOM"]
$state object คือ reactive proxy

Deep proxy มีต้นทุนนิดหน่อยและไม่ใช่ว่าจะต้องการเสมอ — สำหรับข้อมูลใหญ่ที่คุณแค่แทนที่เท่านั้น ใช้ $state.raw raw state reactive ตอน reassign แต่ไม่ reactive ตอน mutate

<script>
let settings = $state.raw({ theme: 'dark', density: 'cozy' });
function setLightTheme() {
// ✅ Reassigning a whole new object IS reactive:
settings = { ...settings, theme: 'light' };
}
// ❌ Mutating is NOT tracked with raw state:
// settings.theme = 'light'; // no update
</script>

นี่ไม่ใช่แค่คำกล่าวอ้างใน docs — มันถูกพิสูจน์แล้ว เทียบตรง ๆ กับ deep (proxied) $state ที่ mutate แบบเดียวกันเป๊ะ:

src/lib/state-raw.svelte.test.ts
import { flushSync } from 'svelte';
import { expect, test } from 'vitest';
test('$state.raw: mutation is NOT tracked, only reassignment is', () => {
const log: string[] = [];
const destroy = $effect.root(() => {
let settings = $state.raw({ theme: 'dark', density: 'cozy' });
$effect(() => {
log.push(settings.theme);
});
flushSync();
expect(log).toEqual(['dark']);
// ❌ mutate a property on raw state — proven to NOT trigger the effect
settings.theme = 'light';
flushSync();
expect(log).toEqual(['dark']); // unchanged — mutation was invisible to the effect
expect(settings.theme).toBe('light'); // the object itself DID change, just not reactively
// ✅ reassign the whole value — this IS tracked
settings = { ...settings, theme: 'blue' };
flushSync();
expect(log).toEqual(['dark', 'blue']);
});
destroy();
});
test('control: the same mutation on deep (proxied) $state DOES trigger the effect', () => {
const log: string[] = [];
const destroy = $effect.root(() => {
let settings = $state({ theme: 'dark' });
$effect(() => {
log.push(settings.theme);
});
flushSync();
settings.theme = 'light'; // mutation, not reassignment
flushSync();
});
destroy();
expect(log).toEqual(['dark', 'light']); // proxy caught the mutation
});
✓ $state.raw: mutation is NOT tracked, only reassignment is
✓ control: the same mutation on deep (proxied) $state DOES trigger the effect
Test Files 1 passed (1)
Tests 2 passed (2)

สังเกตรายละเอียดใน test แรก: settings.theme = 'light' เปลี่ยน object ข้างใต้จริง ๆ (อ่าน settings.theme กลับมาได้ 'light' หลังจากนั้น) — มันแค่ไม่แจ้งใครเลย เพราะไม่มี proxy คอยเฝ้าดูอยู่ นี่คือความผิดพลาดที่พบบ่อยที่สุดของ $state.raw: สมมติว่ามันทำตัวเหมือนข้อมูล immutable ที่ปฏิเสธการ mutate ทั้งที่จริง ๆ มันรับการ mutate แบบเงียบ ๆ แล้วแค่ไม่ react ต่อมันเท่านั้น

ใช้ $state.raw กับค่าที่คุณปฏิบัติแบบ immutable (แทนที่ทั้งก้อน) หรือโครงสร้างใหญ่ที่ไม่อยากเสีย overhead ของ proxy

เพราะ $state object เป็น proxy การส่งมันเข้าโค้ดที่คาดหวัง plain object (console.log ที่โชว์ Proxy, structuredClone, library ภายนอก) อาจเซอร์ไพรส์ $state.snapshot คืนสำเนา plain ที่ไม่ reactive

<script>
let user = $state({ name: 'Ada', roles: ['admin'] });
function save() {
const plain = $state.snapshot(user); // a normal object, not a proxy
api.save(plain);
}
</script>

$state ไม่ได้ผูกอยู่กับ component เท่านั้น — module .svelte.ts (หรือ .svelte.js) ประกาศ state ที่ module scope ได้ ให้อะไรก็ตาม import ได้:

src/lib/module-state.svelte.ts
export const session = $state({ user: null as string | null });

ทุกคนที่ import module นี้แชร์ object เดียวกันเป๊ะ — มี session แค่หนึ่งตัวสำหรับทั้ง process ที่กำลังรัน ไม่ใช่หนึ่งต่อ component instance พิสูจน์แล้ว: component ที่ไม่เคยแตะ code path ที่ set session.user เลยก็ยังเห็นการเปลี่ยนแปลง เพราะมันคือ object เดียวกัน:

src/lib/module-state.svelte.test.ts
import { flushSync } from 'svelte';
import { expect, test } from 'vitest';
import { session } from './module-state.svelte.js';
test('module-scope $state is a SINGLE shared instance across every importer — proves the SSR leak risk', () => {
const destroy = $effect.root(() => {
const seenByComponentA: (string | null)[] = [];
$effect(() => {
seenByComponentA.push(session.user);
});
flushSync();
// "Request 1" sets the user on the shared module state...
session.user = 'alice';
flushSync();
// ...and "component A" (which never touched request 1 directly) sees it,
// because there is exactly ONE `session` object for the whole process.
expect(seenByComponentA).toEqual([null, 'alice']);
});
destroy();
// The leak: a second, unrelated "request" importing the same module still
// sees 'alice' left over from the previous one, because ES module instances
// are cached and shared, not per-request.
expect(session.user).toBe('alice');
});
✓ module-scope $state is a SINGLE shared instance across every importer — proves the SSR leak risk
Test Files 1 passed (1)
Tests 1 passed (1)

assertion สุดท้ายนั้นคือความเสี่ยงทั้งหมดในเวอร์ชันย่อ: ใน SPA ที่รันแค่ในเบราว์เซอร์ object session ตัวเดียวที่แชร์กันมักเป็นสิ่งที่คุณต้องการพอดี (นั่นคือ pattern .svelte.js-module-เป็น-store จากบท Advanced Runes) แต่ทันทีที่โค้ดนี้รันบน serverที่ render request ให้หลาย user object ตัวเดียวที่แชร์ทั้ง process นั้นถูกแชร์ข้ามทุก request official docs พูดตรง ๆ เรื่องนี้:

In many cases this is perfectly fine, but there is a risk: if you mutate the state during server-side rendering (which is discouraged, but entirely possible!)… then the data may be accessible by the next user.

— Replacing global state, svelte.dev

ทางแก้ที่ docs บอกไว้คือ context ไม่ใช่ module-level export: context ถูกสร้างใหม่ต่อ request/render ดังนั้นมันรั่วข้าม user แบบ shared module object ไม่ได้

$state ไม่ใช่ “ตัวแปรที่ทำให้ re-render” — มันคือ reactive cell: การอ่าน register dependency, การเขียนแจ้ง dependent และสำหรับ object/array โดยเฉพาะ cell นี้คือ Proxy ที่ปฏิบัติต่อการเขียน property ว่าเป็น “การเขียนใส่ cell” ด้วย $state.raw คือ cell แบบเดียวกันแต่ไม่มีชั้น Proxy ดังนั้นมีแค่การ reassign ทั้ง cell เท่านั้นที่นับเป็นการเขียน การ mutate เนื้อหาข้างในเปลี่ยนข้อมูลแต่ไม่แตะ cell เอง จึงไม่มีใครถูกแจ้งเลย module-scope $state ก็แค่ cell ที่บังเอิญอยู่บนสุดของไฟล์แทนที่จะอยู่ใน component — นี่แหละคือเหตุผลที่มันถูกแชร์โดยทุกอย่างที่ import ไฟล์นั้น ตราบเท่าที่ process ยังมีชีวิตอยู่

ตัวเลือกได้อะไรเสียอะไร
Deep $state (default)arr.push()/obj.x = y ใช้งานได้เลยเหมือน JS ปกติมี proxy overhead กับทุก object/array แม้ตัวที่ไม่เคย mutate
$state.rawไม่มี proxy overhead; mental model ง่ายสำหรับข้อมูล immutableการ mutate มันถูกเมิน เงียบ ๆ — พิสูจน์แล้วว่าเป็นกับดักจริงที่เจอง่าย
Module state แบบ .svelte.js/.svelte.tsShare ทั้งแอปแบบไม่ต้องมี boilerplate หรือ store APIInstance เดียวสำหรับทั้ง process — พิสูจน์แล้วว่ารั่วข้าม “request” ถ้า mutate ระหว่าง SSR
$state.snapshot ก่อน serialize/logPlain object ไม่มีเซอร์ไพรส์จาก Proxyต้องจำไปเรียกเพิ่มทุกจุดที่ serialize
  • “$state.raw ป้องกันการ mutate เหมือน Object.freeze” พิสูจน์แล้วว่าไม่จริง: การ mutate property ของ raw state สำเร็จแบบเงียบ ๆ (settings.theme กลายเป็น 'light' จริง) — มันแค่ไม่แจ้ง effect หรือ derived ใด ๆ ที่กำลังดูมันอยู่ ไม่มีอะไรถูก freeze reactivity แค่หายไป
  • “reactive $state ที่ export จาก .svelte.js module ถูก scope ต่อ component เหมือน local state ของ hook” พิสูจน์แล้วว่าไม่จริง: มัน object เดียวที่แชร์กับทุกคน import สำหรับทั้ง process — พิสูจน์ตรง ๆ ด้วย component ที่ไม่เคยเขียนค่าเลยแต่ยังเห็นการเปลี่ยนแปลง
  • “Server-side rendering เป็น stateless ต่อ request เสมอ ดังนั้น module state ปลอดภัยเสมอ” framework ไม่ได้ทำให้มันจริงโดยอัตโนมัติ — official docs เตือนตรง ๆ ว่าการ mutate shared module state ระหว่าง SSR อาจ leak ข้อมูลไปให้ request ของ user คนถัดไป Context (ไม่ใช่ module export เปล่า ๆ) คือทางแก้ที่ docs บอกไว้เมื่อ state ต้อง scope ต่อ request
  • “$state.snapshot มีประโยชน์แค่กับ console.log” มันสำคัญทุกที่ที่ต้องการ plain object ที่ไม่ใช่ Proxy: structuredClone, ส่งข้อมูลไป Worker, หรือส่งให้ library ที่ทำ JSON.stringify หรือเช็คการมีอยู่ของ property ซึ่งอาจทำงานแปลก ๆ บน Proxy

เมื่อเจอ $state, $state.raw หรือ reactive state ระดับ module ให้เช็คสิ่งเหล่านี้:

  • ค่า $state.raw ถูก mutate ที่ไหนสักที่ (obj.prop = x, arr.push(...)) โดยคาดว่า UI จะ update — แทนที่จะถูก reassign หรือเปล่า?
  • $state ที่ export จาก module .svelte.ts/.svelte.js ถูก mutate ใน code path ที่อาจรันระหว่าง server-side rendering หรือเปล่า — และ state นั้นควรจะเป็น per-user ไหม?
  • ฟังก์ชันส่ง $state proxy object เข้า JSON.stringify, structuredClone หรือ library ภายนอกที่คาดหวัง plain object โดยไม่เรียก $state.snapshot ก่อนหรือเปล่า?
  • การ mutate ที่ซ้อนลึก (todos[0].tags.push(...)) ถูกสมมติว่า reactive ทั้งที่ค่าชั้นนอกประกาศด้วย $state.raw (ซึ่ง track แค่การ reassign ชั้นบนสุดเท่านั้น ไม่ track การ mutate ที่ซ้อนอยู่ข้างในเลย)?

🐛 หา bug ในโค้ดนี้

function createSettingsStore() {
let settings = $state.raw({ theme: 'dark', fontSize: 14 });
return {
get settings() { return settings; },
setTheme(theme) {
// "Just update the one field that changed"
settings.theme = theme;
},
};
}
เฉลย

setTheme mutate settings.theme ตรง ๆ บนค่าที่ประกาศด้วย $state.raw — แต่ raw state reactive ตอน reassign เท่านั้น ไม่ใช่ mutate การ mutate เกิดขึ้นจริง (settings.theme กลายเป็นค่าใหม่จริงถ้าคุณอ่านมัน) แต่ไม่มีอะไรที่อ่าน settings แบบ reactive — $effect, $derived หรือ template — ถูกแจ้งเลย เพราะไม่มี proxy คอยเฝ้าดูการเขียน property บน raw state พิสูจน์ในโปรบแล้ว: การ mutate settings.theme บน $state.raw ทำให้ log ของ effect ไม่เปลี่ยน (['dark']) ในขณะที่การ mutate แบบเดียวกันเป๊ะบน $state แบบ deep ปกติทำให้ effect รันใหม่ถูกต้อง (['dark', 'light']) ทางแก้คือ reassign ทั้ง object — settings = { ...settings, theme } — ซึ่ง test เดียวกันพิสูจน์แล้วว่ามัน trigger dependent จริง

update $state ใน Svelte 5 ทำอย่างไร?
ทำไม todos.push(newTodo) ถึง reactive เมื่อ todos เป็น $state([])?
$effect จะ log ใหม่หลัง settings.theme = 'light' หรือไม่?
let settings = $state.raw({ theme: 'dark' });
$effect(() => console.log(settings.theme));
settings.theme = 'light'; // mutate, not reassign
$state.snapshot(user) คืนค่าอะไร?
component สองตัวที่ไม่เกี่ยวข้องกัน import { session } from './session.svelte.ts' ทั้งคู่ อะไรคือความจริง?
// src/lib/session.svelte.ts
export const session = $state({ user: null });
ทำไม official Svelte docs ถึงเตือนไม่ให้ mutate shared .svelte.js module state ระหว่าง server-side rendering?