Runes vs Stores
Counter เดิม สองแบบ
หัวข้อที่มีชื่อว่า “Counter เดิม สองแบบ”ก่อน Svelte 5 การแชร์ reactive state ข้าม component หมายถึง store โดยพื้นฐาน ตอนนี้คุณใส่ $state ใน module แล้ว export ได้เลย Rune ทำงานนอก component ได้ — แต่เฉพาะไฟล์ที่มี extension .svelte.js หรือ .svelte.ts เท่านั้น ซึ่งบอก compiler ให้เปิดการประมวลผล rune
เวอร์ชัน store:
import { writable } from 'svelte/store';export const count = writable(0);<script> import { count } from './counter-store.js';</script><button onclick={() => count.update((n) => n + 1)}>{$count}</button>เวอร์ชัน rune:
export const counter = $state({ value: 0 });<script> import { counter } from './counter-runes.svelte.js';</script><button onclick={() => counter.value++}>{counter.value}</button>ไม่มี writable ไม่มี set/update ไม่มี $ prefix — คุณอ่านและ mutate counter.value เหมือน object ธรรมดา แล้วมัน reactive ทุกที่ที่ใช้
ทำไมต้อง export object ไม่ใช่ค่าเปล่า
หัวข้อที่มีชื่อว่า “ทำไมต้อง export object ไม่ใช่ค่าเปล่า”สังเกตว่าเวอร์ชัน rune export { value: 0 } ไม่ใช่ 0 let x = $state(0) เปล่า ๆ ที่ export ออกไปเชื่อมข้าม module boundary ไม่ได้ — การ import มันคัดลอกค่า ทำให้ reactivity หายไป การ export object (หรือฟังก์ชันที่ return reactive state) ทำให้ทุกคนชี้ไปที่ proxy reactive ตัวเดียวกัน ดังนั้นการ mutate counter.value จึงกระจายไปทั่ว นี่คือกับดักเดียวของ rune ระดับ module
Class instance singleton เทียบ factory ที่พิสูจน์แล้ว
หัวข้อที่มีชื่อว่า “Class instance singleton เทียบ factory ที่พิสูจน์แล้ว”กับดัก object-vs-ค่าเปล่าแบบเดียวกันปรากฏในรูปแบบอื่นทันทีที่คุณเปลี่ยนไปใช้ class แทน object literal ธรรมดา class ที่ field เป็น rune reactive อยู่แล้วไม่ว่าจะแบบไหน — คำถามคือคุณทำอะไรกับ instance ของมัน:
export class Counter { count = $state(0); double = $derived(this.count * 2); increment() { this.count++; }}
// Module-level: an INSTANCE of the class, exported directly — a singleton,// exactly like exporting `$state({...})` directly from a module.export const singletonCounter = new Counter();
// A factory: calling it creates a brand-new instance every time.export function createCounter() { return new Counter();}ทั้งสองแบบใช้ได้จริงทั้งคู่ — แค่ตอบคำถามคนละอย่าง export const singletonCounter = new Counter() ให้ทุกคนที่ import object เดียวกัน เหมือนกับการ export $state({...}) ตรง ๆ (ประเด็นทั้งหมดของ module นี้ข้างบน) export function createCounter() ให้ทุก caller ได้ instance ของตัวเอง แยกกัน — พิสูจน์ตรง ๆ:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';import { Counter, createCounter, singletonCounter } from './class-vs-factory.svelte.js';
test('a class instance exported directly from module scope is a SHARED singleton (same object for every importer)', () => { const destroy = $effect.root(() => { const a = singletonCounter; const b = singletonCounter; // "two importers" of the same module export a.increment(); expect(b.count).toBe(1); // b sees a's change: same instance expect(a).toBe(b); }); destroy();});
test('a factory function creates INDEPENDENT instances — no shared state between callers', () => { const destroy = $effect.root(() => { const a = createCounter(); const b = createCounter(); a.increment(); expect(a.count).toBe(1); expect(b.count).toBe(0); // b is unaffected: a separate instance expect(a).not.toBe(b); }); destroy();});
test('class fields declared with runes are reactive exactly like plain $state/$derived variables', () => { const log: number[] = []; const destroy = $effect.root(() => { const counter = createCounter(); $effect(() => { log.push(counter.double); }); flushSync(); expect(log).toEqual([0]); counter.increment(); flushSync(); expect(log).toEqual([0, 2]); }); destroy();});✓ a class instance exported directly from module scope is a SHARED singleton (same object for every importer)✓ a factory function creates INDEPENDENT instances — no shared state between callers✓ class fields declared with runes are reactive exactly like plain $state/$derived variables
Test Files 1 passed (1) Tests 3 passed (3)กติกาคือแบบเดียวกับส่วนข้างบน แค่เอามาใช้กับ class แทน object literal: สิ่งที่ถูกแชร์คือสิ่งที่คุณ export ไม่ใช่ว่ามันเป็น class หรือเปล่า Export instance ที่สร้างแล้ว (new Counter()) แล้วทุกคนที่ import จะแชร์มัน — เหมาะกับ state ที่ global จริง ๆ instance เดียว (แพทเทิร์นที่ใช้ตลอดตัวอย่าง module ในบท Cross-Component State) Export factory (createCounter) แล้วทุก จุดที่เรียก จะได้ของตัวเอง — เหมาะเมื่อมี instance อิสระมากกว่าหนึ่งตัวพร้อมกันได้ (form, modal, แถวต่อ item) shape เดียวกับที่ setContext/getContext ใช้ต่อ subtree ในบทเดียวกันนั้น
เทียบข้าง ๆ กัน
หัวข้อที่มีชื่อว่า “เทียบข้าง ๆ กัน”Store (writable) | Runes (.svelte.js) | |
|---|---|---|
| อ่านใน component | $count | counter.value |
| Update | count.set(x) / count.update(fn) | counter.value = x |
| นอก component | ใช้ได้ทุกที่ | ต้องการ .svelte.js/.svelte.ts |
| Subscribe contract | มี (interop, observable) | ไม่มี |
| พิธีการ | มากกว่า (method, $) | น้อยกว่า (อ่าน/เขียนตรง ๆ) |
flowchart TB q["Sharing reactive state?"] --> interop["Need the subscribe contract (observables, external push source)?"] interop --> yes["Yes: use a store"] interop --> no["No: export $state from a .svelte.js module"]
เชื่อมสองโลก: fromStore กับ toStore
หัวข้อที่มีชื่อว่า “เชื่อมสองโลก: fromStore กับ toStore”Rune กับ store ไม่จำเป็นต้องเลือกอย่างใดอย่างหนึ่งในโค้ดเบสเดียว — svelte/store export fromStore และ toStore เพื่อข้าม boundary ทั้งสองทิศทางโดยเฉพาะ พิสูจน์ที่นี่ครบทั้งสองทาง:
import { flushSync } from 'svelte';import { fromStore, get, toStore, writable } from 'svelte/store';import { expect, test } from 'vitest';
test('fromStore: bridges a store into a reactive { current } object, both directions', () => { const store = writable(0); const destroy = $effect.root(() => { const count = fromStore(store); const log: number[] = [];
$effect(() => { log.push(count.current); });
flushSync(); expect(log).toEqual([0]);
store.set(1); // store -> rune direction flushSync(); expect(log).toEqual([0, 1]);
count.current = 2; // rune -> store direction (fromStore's .current is settable) expect(get(store)).toBe(2); flushSync(); expect(log).toEqual([0, 1, 2]); }); destroy();});
test('toStore: bridges $state into a store, both directions', () => { const destroy = $effect.root(() => { let count = $state(0); const store = toStore( () => count, (v) => (count = v), );
const seen: number[] = []; const countLog: number[] = []; const unsub = store.subscribe((v) => seen.push(v)); $effect(() => { countLog.push(count); // tracked read, proves the rune side too }); flushSync(); expect(seen).toEqual([0]); expect(countLog).toEqual([0]);
count = 1; // rune -> store direction flushSync(); expect(seen).toEqual([0, 1]);
store.set(2); // store -> rune direction flushSync(); expect(countLog).toEqual([0, 1, 2]);
unsub(); }); destroy();});
test('toStore with only a getter produces a read-only store (no set/update)', () => { const destroy = $effect.root(() => { let count = $state(5); const store = toStore(() => count); expect((store as { set?: unknown }).set).toBeUndefined(); expect(get(store)).toBe(5); count = 6; flushSync(); expect(get(store)).toBe(6); }); destroy();});✓ fromStore: bridges a store into a reactive { current } object, both directions✓ toStore: bridges $state into a store, both directions✓ toStore with only a getter produces a read-only store (no set/update)
Test Files 1 passed (1) Tests 3 passed (3)fromStore(store) แปลง store ใด ๆ ให้เป็น { current } — shape ที่เป็นมิตรกับ rune ที่คุณอ่านใน $derived/template ได้ และถ้า store เป็น writable ก็ assign ตรง ๆ ได้ toStore(get, set) ไปทางกลับกัน: ให้ getter (และ setter ถ้าอยาก) ที่ closure ทับ $state มันสร้าง store จริงที่มี subscribe เพื่อส่งให้ API ใด ๆ ที่คาดหวัง store contract (library ที่ใช้ store อยู่แล้ว, โค้ด shape แบบ $app/stores, prop ของ component ที่ type เป็น Readable<T>) ไม่มีตัวไหน duplicate state เลย — ทั้งคู่รักษา single source of truth เดียวและแปลการอ่าน/เขียนข้าม boundary แบบ live
เมื่อไรควรใช้อันไหน
หัวข้อที่มีชื่อว่า “เมื่อไรควรใช้อันไหน”- Rune (ค่า default) shared app state ส่วนใหญ่ — cart, theme, current-user object ง่ายกว่า boilerplate น้อยกว่า และ mental model เดียวกับ component-local state Class instance singleton หรือ factory ที่พิสูจน์ข้างบนครอบคลุมสอง shape ที่มันเป็นได้: แชร์โดย default เทียบกับอิสระต่อ caller
- Store เมื่อคุณต้องการcontract: ห่อ WebSocket หรือ push source อื่นด้วย
readable(หรือcreateSubscriberจากบท Built-in Stores), interop กับ observable แบบ RxJS หรือดูแลโค้ดที่ใช้ store อยู่แล้ว - โค้ดเก่าที่คุณจะเจอ ไม่ใช่เขียนเอง
$app/storesของ SvelteKit เอง —page,navigating,updatedเป็น store ที่มี$นำหน้า — เป็นตัวอย่างจริงของ “โค้ดที่ใช้ store อยู่แล้ว” ที่คุณจะเจอในโค้ดเบสเก่าและโค้ดที่ AI generate จากมัน ตั้งแต่ SvelteKit 2.12 docs ของ framework เองพูดตรง ๆ ว่า: “$app/storesis deprecated and subject to be removed in SvelteKit 3” ตัวแทนคือ$app/stateซึ่งเป็น rune-based — ข้อมูลเดิม ไม่มี$prefix และ fine-grained กว่า (updatepage.stateไม่ invalidatepage.dataอีกต่อไป)npx sv migrate app-stateทำการเขียนใหม่เชิงกลไกส่วนใหญ่ให้อัตโนมัติ การจำimport { page } from '$app/stores'ให้ออกว่าเป็นรูปแบบเก่า — ไม่ใช่บั๊ก แต่เป็นสัญญาณให้ migrate — คือสิ่งที่ section “อ่านโค้ดที่ AI เขียน” ของคอร์สนี้มีไว้เพื่อสิ่งนี้พอดี
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Module ที่ถือ $state กับ store คือคำตอบสองแบบต่อคำถามเดียวกัน: “single source of truth อยู่ที่ไหน และผู้อ่านรู้ได้ยังไงว่ามันเปลี่ยน” คำตอบของ rune เป็นเชิงโครงสร้าง: compiler ผูกผู้อ่านเข้ากับ signal graph เดียวกับตัวค่าเองตรง ๆ จึงไม่มีขั้นตอนแจ้งเตือนแยกให้ต้อง opt-in คำตอบของ store เป็นเชิงพฤติกรรม: object สัญญาว่าจะเรียก callback ของคุณ และทุกอย่างที่เหลือ — น้ำตาล $, fromStore, subscribe มือ — คือวิธีทำตามหรือใช้สัญญานั้น class instance ก็แค่มัด field ที่เป็น rune ไว้ด้วยกัน — มันจะทำตัวเหมือน singleton ของ module rune หรือ instance ใหม่ต่อการใช้งาน ขึ้นอยู่กับว่าคุณ export instance หรือฟังก์ชันที่สร้างมัน เป๊ะเหมือนกับ object literal ธรรมดา
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
$state ใน module .svelte.js (object literal หรือ class instance) | ไม่มีพิธีการ mental model เดียวกับ component state semantics ของ singleton ที่พิสูจน์แล้ว | ต้องจำกฎ object-ไม่ใช่-ค่าเปล่า; instance เดียวสำหรับทั้ง process |
Factory function (createCounter()) | Instance อิสระต่อการเรียก พิสูจน์แล้ว — ไม่มี cross-talk โดยไม่ตั้งใจระหว่าง caller | Caller ต้องตัดสินใจว่าจะเรียกเมื่อไรและเก็บผลลัพธ์ไว้ที่ไหน |
writable/readable/derived | Subscribe contract สำหรับ interop; ใช้ได้ในไฟล์ .js ใดก็ได้ | พิธีการมากกว่า rune สำหรับ app state ธรรมดา |
fromStore/toStore | ข้าม boundary โดยไม่ duplicate state พิสูจน์แล้วทั้งสองทิศทาง | มีการเรียกเพิ่มที่ boundary; ยังต้องถือ mental model สองแบบพร้อมกัน |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “การ export class instance ต่างจากการ export
$state({...})โดยพื้นฐาน” พิสูจน์แล้วว่าผิด: ทั้งคู่คือแค่ “module export object เดียว” — class instance singleton ถูกแชร์ข้ามทุก importer ด้วยเหตุผลเดียวกันเป๊ะกับที่$state({...})เปล่า ๆ ที่ export ถูกแชร์ - “factory function อย่าง
createCounter()แชร์ state กับแพทเทิร์น singleton ระดับ module” พิสูจน์แล้วว่าผิด:createCounter()ที่เรียกสองครั้งสร้าง counter อิสระสองตัวที่ไม่แทรกแซงกัน — มีแค่การ export instance ตรง ๆ (หรือ object เปล่า) เท่านั้นที่ถูกแชร์ - “
fromStore/toStorecopy ค่าครั้งเดียว ดังนั้นสองฝั่งอาจ drift ไม่ตรงกันได้” พิสูจน์แล้วว่าผิดทั้งสองทิศทาง: การเขียนที่ฝั่งไหนของสะพาน (store หรือ rune) มองเห็นได้ทันทีจากอีกฝั่ง ไม่มี polling หรือ re-sync มือ - “
$app/storesเป็นแค่ชื่อเก่าของสิ่งเดียวกับ$app/state” มันคือข้อมูลเบื้องหลังเดียวกัน แต่ docs ของ framework เองเรียก$app/storesว่า deprecated ตั้งแต่ SvelteKit 2.12 จะถูกลบใน SvelteKit 3 — มันคือเป้าหมายให้ migrate ไม่ใช่ทางเลือกเชิงสไตล์
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อเจอ $state ระดับ module, class instance, factory function หรือ $app/stores ให้เช็คสิ่งเหล่านี้:
- Class instance ถูก export ตรง ๆ (
export const foo = new Thing()) ตอนที่โค้ดต้องการ instance ใหม่ต่อการใช้งานจริง ๆ (object ต่อแถว, ต่อ modal, ต่อ request) — หรือกลับกัน factory ถูกเรียกในแบบที่สร้าง instance ใหม่โดยไม่ตั้งใจทุก render ทั้งที่ตั้งใจให้เป็น singleton ที่แชร์? export let x = $state(0)เปล่า ๆ (ไม่ใช่ object ไม่ใช่ class instance) ถูก import ที่อื่นแล้วคาดว่าจะ update สด ๆ ไหม? พิสูจน์ในบทนี้แล้ว: มีแค่ object/instance export เท่านั้นที่รักษา reactive link ไว้import { page } from '$app/stores'(พร้อมการอ่าน$page) ปรากฏในโค้ดที่ตั้งเป้า SvelteKit เวอร์ชันล่าสุดไหม — สัญญาณชัดว่าเป็น training data ของ AI ที่มาก่อนการ migrate ไป$app/stateใน 2.12 คุ้มที่จะ flag แม้ว่ามันจะยังทำงานได้อยู่วันนี้?- โค้ด duplicate ข้อมูลเดียวกันเข้าทั้ง store และตัวแปร
$stateมือ แล้วคอย sync กันเองไหม — แทนที่จะใช้fromStore/toStoreเชื่อมค่าที่เดียวกันเข้าด้วยกัน?
🐛 หา bug ในโค้ดนี้
class CurrentUser {name = $state('');isAdmin = $state(false);}
export const currentUser = new CurrentUser();
// Usage in a SvelteKit route:// src/routes/+page.server.tsexport async function load({ locals }) {currentUser.name = locals.user.name;currentUser.isAdmin = locals.user.isAdmin;return {};}เฉลย
currentUser คือ class instance ที่ export ตรง ๆ จาก module — singleton ที่แชร์กันทั้ง server process ด้วยเหตุผลเดียวกันเป๊ะกับที่ $state({...}) เปล่า ๆ ระดับ module ที่ export ตรง ๆ เป็น (พิสูจน์แล้วในบท Cross-Component State ซึ่งพิสูจน์กับดักเดียวกันเป๊ะสำหรับ object ธรรมดา) การ mutate currentUser.name/currentUser.isAdmin ข้างใน load function — ฝั่ง server ต่อ request — เขียนลง instance ที่แชร์ตัวเดียวนั้น ดังนั้น request ที่สอง ที่เกิดพร้อมกันสำหรับ user คนละคน อาจเห็นชื่อและ admin flag ของ user คนแรกก่อนที่ load ของตัวเองจะเสร็จด้วยซ้ำ การใช้ class แทน plain object ไม่เปลี่ยนความเสี่ยงนี้เลย — สิ่งที่สำคัญคือ instance เป็น module-level export ไม่ใช่ว่ามันเป็น class หรือเปล่า ทางแก้คือแบบเดียวกับที่พิสูจน์แล้วสำหรับ module $state: สร้าง state ของ user ใหม่ต่อ request — factory สไตล์ createCounter() ที่เรียกข้างใน load/layout หรือ setContext/getContext ตามแพทเทิร์นในบทเดียวกันนั้น ไม่ใช่ instance ที่ export ตรง ๆ ที่ถือข้อมูลต่อ user