The $state Rune
ประกาศ reactive state
หัวข้อที่มีชื่อว่า “ประกาศ reactive state”$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> Deep reactivity ผ่าน proxy
หัวข้อที่มีชื่อว่า “Deep reactivity ผ่าน proxy”เมื่อคุณส่ง 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.raw ที่พิสูจน์แล้ว
หัวข้อที่มีชื่อว่า “ปิดใช้: $state.raw ที่พิสูจน์แล้ว”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 แบบเดียวกันเป๊ะ:
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
เอาสำเนา plain: $state.snapshot
หัวข้อที่มีชื่อว่า “เอาสำเนา plain: $state.snapshot”เพราะ $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>Module state ใน .svelte.ts และกับดัก SSR
หัวข้อที่มีชื่อว่า “Module state ใน .svelte.ts และกับดัก SSR”$state ไม่ได้ผูกอยู่กับ component เท่านั้น — module .svelte.ts (หรือ .svelte.js) ประกาศ state ที่ module scope ได้ ให้อะไรก็ตาม import ได้:
export const session = $state({ user: null as string | null });ทุกคนที่ import module นี้แชร์ object เดียวกันเป๊ะ — มี session แค่หนึ่งตัวสำหรับทั้ง process ที่กำลังรัน ไม่ใช่หนึ่งต่อ component instance พิสูจน์แล้ว: component ที่ไม่เคยแตะ code path ที่ set session.user เลยก็ยังเห็นการเปลี่ยนแปลง เพราะมันคือ object เดียวกัน:
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 ไม่ได้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”$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.ts | Share ทั้งแอปแบบไม่ต้องมี boilerplate หรือ store API | Instance เดียวสำหรับทั้ง process — พิสูจน์แล้วว่ารั่วข้าม “request” ถ้า mutate ระหว่าง SSR |
$state.snapshot ก่อน serialize/log | Plain object ไม่มีเซอร์ไพรส์จาก Proxy | ต้องจำไปเรียกเพิ่มทุกจุดที่ serialize |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
$state.rawป้องกันการ mutate เหมือนObject.freeze” พิสูจน์แล้วว่าไม่จริง: การ mutate property ของ raw state สำเร็จแบบเงียบ ๆ (settings.themeกลายเป็น'light'จริง) — มันแค่ไม่แจ้ง effect หรือ derived ใด ๆ ที่กำลังดูมันอยู่ ไม่มีอะไรถูก freeze reactivity แค่หายไป - “reactive
$stateที่ export จาก.svelte.jsmodule ถูก 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
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อเจอ $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 ไหม?- ฟังก์ชันส่ง
$stateproxy 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 จริง