Built-in Stores
writable — ตัวหลัก
หัวข้อที่มีชื่อว่า “writable — ตัวหลัก”writable(initial) จาก svelte/store สร้าง store ที่คุณอ่านและเขียนได้ มันมี 3 method: set(value) (แทนที่), update(fn) (เปลี่ยนตามค่าปัจจุบัน) และ subscribe(fn) (contract)
import { writable } from 'svelte/store';
export const count = writable(0);<script> import { count } from './count.js';</script>
<button onclick={() => count.update((n) => n + 1)}>increment</button><button onclick={() => count.set(0)}>reset</button><p>{$count}</p><!-- $ reads the value and re-renders -->component ไหนก็ตามที่ import count จะแชร์ store เดียวกัน — เขียนที่หนึ่งแล้ว update ทุกที่ที่อ่าน นี่คือ shared state ในบรรทัดเดียว
readable — ค่าที่ set จากภายนอกไม่ได้
หัวข้อที่มีชื่อว่า “readable — ค่าที่ set จากภายนอกไม่ได้”readable(initial, start) สร้าง store ที่ค่าถูกควบคุมโดย start function ไม่ใช่โดยผู้ใช้ start function รับ set (และ update) รันตอน subscriber แรกมาถึง และ return function stop บท The Store Contract พิสูจน์ไปแล้วว่า stop รันตอน unsubscribe ครั้งสุดท้าย — ที่นี่คือ guarantee เดียวกัน แต่โชว์ตรง ๆ บน readable ด้วย interval จริง:
// A clock nobody can set from outside.import { readable } from 'svelte/store';
export const time = readable(new Date(), (set) => { const id = setInterval(() => set(new Date()), 1000); return () => clearInterval(id); // cleanup when no one is listening});import { readable } from 'svelte/store';import { expect, test } from 'vitest';
test('readable: start runs on first subscriber, stop runs on LAST unsubscribe (not each one)', () => { let starts = 0; let stops = 0; const store = readable(0, (set) => { starts++; return () => { stops++; }; });
const unsub1 = store.subscribe(() => {}); expect(starts).toBe(1); expect(stops).toBe(0);
const unsub2 = store.subscribe(() => {}); // second subscriber: start NOT called again expect(starts).toBe(1);
unsub1(); // one of two unsubscribes: stop NOT called yet expect(stops).toBe(0);
unsub2(); // the LAST unsubscribe: stop runs now expect(stops).toBe(1);});
test('readable: after all unsubscribe then a new subscriber arrives, start runs again (fresh lifecycle)', () => { let starts = 0; const store = readable(0, (set) => { starts++; return () => {}; });
const unsub1 = store.subscribe(() => {}); expect(starts).toBe(1); unsub1();
const unsub2 = store.subscribe(() => {}); expect(starts).toBe(2); // no subscribers were left, so start runs again unsub2();});✓ readable: start runs on first subscriber, stop runs on LAST unsubscribe (not each one)✓ readable: after all unsubscribe then a new subscriber arrives, start runs again (fresh lifecycle)
Test Files 1 passed (1) Tests 2 passed (2)เอามาใช้กับ time: setInterval ไม่มีอยู่จริงจนกว่าจะมีคน subscribe และถูกเคลียร์จริง ๆ ทันทีที่ subscriber คนสุดท้าย (component สุดท้ายที่ใช้ $time) หายไป — พิสูจน์ข้างบนแล้วด้วยคู่ start/stop ที่นับจำนวนแทน setInterval/clearInterval lifecycle แบบ start/stop นี้แปลว่าไม่มีงานเสียเปล่าตอนไม่มีใครฟังอยู่
derived — คำนวณจาก store อื่น
หัวข้อที่มีชื่อว่า “derived — คำนวณจาก store อื่น”derived(source, fn) สร้าง store ใหม่จาก store ที่มีอยู่หนึ่งตัวหรือมากกว่า คำนวณใหม่ทุกครั้งที่ source เปลี่ยน
import { derived } from 'svelte/store';import { count } from './count.js';
export const doubled = derived(count, ($count) => $count * 2);
// From multiple stores — pass an array:export const summary = derived( [count, doubled], ([$count, $doubled]) => `${$count} doubled is ${$doubled}`);flowchart LR w["writable: set, update, subscribe"] --> use["read with $ in a component"] r["readable: start/stop controls the value"] --> use d["derived: computed from other stores"] --> use
svelte/reactivity: ทางเลือกแบบ rune-native ที่พิสูจน์แล้ว
หัวข้อที่มีชื่อว่า “svelte/reactivity: ทางเลือกแบบ rune-native ที่พิสูจน์แล้ว”ตั้งแต่ Svelte 5.8 svelte/reactivity มาพร้อมของทดแทนแบบ rune-native ที่ใช้แทน built-in ได้ทันที สำหรับสิ่งที่ readable/derived เคยเป็นทางเดียวที่จะได้มา: SvelteMap, SvelteSet, SvelteDate, SvelteURL, SvelteURLSearchParams, MediaQuery และ createSubscriber ระดับล่างที่ทุกตัวข้างบนถูกสร้างขึ้นบนมัน พวกมันไม่มี subscribe, $ หรือ store contract เลย — เป็นแค่ class ธรรมดาที่การอ่านและเรียก method ถูก track เหมือน $state ใช้ตรง ๆ ใน $derived/$effect/template ได้เลย
| ต้องการ | แบบ store | แบบ svelte/reactivity |
|---|---|---|
Map ที่ reactive | writable(new Map()) ต้องแทนที่ทั้ง Map ทุกครั้งที่ mutate | new SvelteMap() — .set()/.delete() reactive ตรง ๆ พิสูจน์ข้างล่าง |
Set ที่ reactive | writable(new Set()) แพทเทิร์นแทนที่ทั้งก้อนเดียวกัน | new SvelteSet() |
| เวลาปัจจุบันที่ tick | readable(new Date(), (set) => { const id = setInterval(...); return () => clearInterval(id); }) | new SvelteDate() (อัปเดตตัวเอง) |
| URL ปัจจุบัน | เขียน store เองรอบ location/popstate | new SvelteURL(location.href) |
Push source ภายนอก (WebSocket, IntersectionObserver, media query) | readable(initial, (set) => { const off = source.on(...); return () => off(); }) | createSubscriber((update) => { const off = source.on(() => update()); return () => off(); }) หลัง getter |
| Media query | เขียน readable ห่อ matchMedia + listener change เอง | new MediaQuery('min-width: 800px') (build บน createSubscriber ตามที่โชว์ข้างล่าง) |
SvelteMap: reactive ระดับ method — พิสูจน์เทียบกับ Map ธรรมดา
หัวข้อที่มีชื่อว่า “SvelteMap: reactive ระดับ method — พิสูจน์เทียบกับ Map ธรรมดา”ความต่างที่สำคัญจาก plain object/array ภายใต้ $state: proxy ของ $state instrument การเขียน property ธรรมดาและการ mutate array แต่ไม่instrument method ของ Map/Set อย่าง .set() หรือ .delete() SvelteMap ทำ เพราะมันคือ class ที่สร้างมาเพื่อ reactive โดยเฉพาะ ไม่ใช่ plain object ที่ถูก proxy:
import { flushSync } from 'svelte';import { SvelteMap } from 'svelte/reactivity';import { expect, test } from 'vitest';
test('SvelteMap: method-level mutation (.set/.delete) is reactive', () => { const log: number[] = []; const destroy = $effect.root(() => { const map = new SvelteMap<string, number>();
$effect(() => { log.push(map.size); });
flushSync(); expect(log).toEqual([0]);
map.set('a', 1); // method-level mutation flushSync(); expect(log).toEqual([0, 1]);
map.delete('a'); flushSync(); expect(log).toEqual([0, 1, 0]); }); destroy();});
test('control: a plain Map wrapped in $state is NOT reactive at the method level (.set/.delete)', () => { const log: number[] = []; const destroy = $effect.root(() => { let map = $state(new Map<string, number>());
$effect(() => { log.push(map.size); });
flushSync(); expect(log).toEqual([0]);
map.set('a', 1); // mutating the plain Map instance through $state's proxy flushSync(); // $state's proxy only instruments plain object/array property writes and // a known allowlist of built-ins — NOT arbitrary Map/Set method calls, so // this mutation is invisible to the effect. expect(log).toEqual([0]); expect(map.size).toBe(1); // the mutation genuinely happened, just not reactively }); destroy();});
test('control: reassigning the whole plain Map (new Map with the entry) IS reactive', () => { const log: number[] = []; const destroy = $effect.root(() => { let map = $state(new Map<string, number>());
$effect(() => { log.push(map.size); });
flushSync(); const next = new Map<string, number>(); next.set('a', 1); map = next; // reassignment of the WHOLE value, not a mutation flushSync(); expect(log).toEqual([0, 1]); }); destroy();});✓ SvelteMap: method-level mutation (.set/.delete) is reactive✓ control: a plain Map wrapped in $state is NOT reactive at the method level (.set/.delete)✓ control: reassigning the whole plain Map (new Map with the entry) IS reactive
Test Files 1 passed (1) Tests 3 passed (3)นี่คือสิ่งที่สำคัญที่สุดที่ต้องรู้เกี่ยวกับ $state กับ built-in collection type: $state(new Map()) ไม่ใช่ “Map ที่ reactive” มันคือ deep proxy รอบโครงสร้าง object/array ธรรมดา และ instance ของ Map/Set อยู่นอกขอบเขตที่ proxy นั้น instrument ระดับ method — การ mutate เกิดขึ้นจริง (map.size เป็น 1 จริงหลังจากนั้น) แค่ไม่ notify อะไรเลย ทางแก้คือแทนที่ทั้ง Map ทุกครั้งที่เปลี่ยน (แพทเทิร์น next/reassign ข้างบน) หรือใช้ SvelteMap เพื่อให้ .set()/.delete() ทำงานตามที่มันควรจะเป็นเป๊ะ
createSubscriber: primitive ที่อยู่เบื้องหลังทุกตัว
หัวข้อที่มีชื่อว่า “createSubscriber: primitive ที่อยู่เบื้องหลังทุกตัว”createSubscriber คือสิ่งที่ MediaQuery (และตัวอื่นใน svelte/reactivity) ถูกสร้างขึ้นบน: มัน return subscribe function ที่ตอนถูกเรียกจากใน tracked context (effect, derived) จะรัน callback start แค่ครั้งเดียวตราบใดที่ยังมี tracked reader อย่างน้อยหนึ่งตัวมีชีวิตอยู่ แล้วรัน cleanup ที่มัน return ครั้งเดียวตอนตัวสุดท้ายหายไป — shape start/stop เดียวกับ readable แต่เชื่อมตรงเข้ากับ rune tracking แทน store contract:
import { flushSync } from 'svelte';import { createSubscriber } from 'svelte/reactivity';import { expect, test } from 'vitest';
// A minimal fake push source: emit(value) notifies every listener.class FakeEmitter { #listeners = new Set<(v: number) => void>(); on(fn: (v: number) => void) { this.#listeners.add(fn); return () => this.#listeners.delete(fn); } emit(v: number) { for (const fn of this.#listeners) fn(v); } get listenerCount() { return this.#listeners.size; }}
class Live { #emitter: FakeEmitter; #value = 0; #subscribe: () => void; startCalls = 0; stopCalls = 0;
constructor(emitter: FakeEmitter) { this.#emitter = emitter; this.#subscribe = createSubscriber((update) => { this.startCalls++; const off = this.#emitter.on((v) => { this.#value = v; update(); }); return () => { this.stopCalls++; off(); }; }); }
get current() { this.#subscribe(); return this.#value; }}
test('createSubscriber wraps a push source: start runs once for the first effect, stop once the last unsubscribes', () => { const emitter = new FakeEmitter(); const live = new Live(emitter); const log: number[] = [];
const destroy = $effect.root(() => { $effect(() => { log.push(live.current); });
flushSync(); expect(log).toEqual([0]); expect(live.startCalls).toBe(1); expect(emitter.listenerCount).toBe(1);
emitter.emit(1); // external push -> update() -> effect re-runs flushSync(); expect(log).toEqual([0, 1]);
emitter.emit(2); flushSync(); expect(log).toEqual([0, 1, 2]); });
destroy(); // root destroyed -> last subscriber gone});
test('createSubscriber: start is called only once across two effects that both read .current (shared subscription)', () => { const emitter = new FakeEmitter(); const live = new Live(emitter);
const destroy = $effect.root(() => { $effect(() => { live.current; }); $effect(() => { live.current; }); flushSync(); expect(live.startCalls).toBe(1); // one shared start for both effects expect(emitter.listenerCount).toBe(1); }); destroy();});
test('createSubscriber: stop actually runs (asserted after a real microtask tick, not just flushSync)', async () => { const emitter = new FakeEmitter(); const live = new Live(emitter);
const destroy = $effect.root(() => { $effect(() => { live.current; }); flushSync(); }); expect(live.stopCalls).toBe(0); destroy(); // create-subscriber's teardown is wrapped in a microtask; a real microtask // tick (not flushSync) is required to observe it. await Promise.resolve(); await Promise.resolve(); expect(live.stopCalls).toBe(1); expect(emitter.listenerCount).toBe(0);});✓ createSubscriber wraps a push source: start runs once for the first effect, stop once the last unsubscribes✓ createSubscriber: start is called only once across two effects that both read .current (shared subscription)✓ createSubscriber: stop actually runs (asserted after a real microtask tick, not just flushSync)
Test Files 1 passed (1) Tests 3 passed (3)นี่คือ shape เดียวกับที่ readable ให้คุณ ลบ store contract ออก — ไม่มี subscribe/$ มีแค่ getter ที่ถูก track เหมือนอ่าน $state เป๊ะ นี่คือวิธีที่ MediaQuery ถูก implement จริงใน source ของ svelte/reactivity เอง: #subscribe = createSubscriber((update) => on(mediaQueryList, 'change', update)) หลัง get current()
svelte/reactivity/window
หัวข้อที่มีชื่อว่า “svelte/reactivity/window”Entry point แยกต่างหาก svelte/reactivity/window export reactive binding สำเร็จรูปสำหรับ property ของ window ที่ไม่งั้นคุณต้องต่อเองด้วย listener resize/scroll/online: innerWidth, innerHeight, outerWidth, outerHeight, scrollX, scrollY, screenLeft, screenTop, online และ devicePixelRatio แต่ละตัวเป็น object ที่มี getter .current build บน createSubscriber primitive เดียวกับที่พิสูจน์ข้างบน:
import { innerWidth } from 'svelte/reactivity/window';
export function isNarrow() { // innerWidth.current is typed number | undefined — undefined on the // server, where there is no window at all — so a fallback is required. return (innerWidth.current ?? 1024) < 768;}ยืนยันแล้วว่ามีจริงใน Svelte ที่ติดตั้ง (5.57.1): svelte/reactivity/window เป็น package export จริง และทุกชื่อข้างบน export จาก source ของมัน type เองทำให้ caveat เรื่อง server ชัดเจนแทนที่จะเป็นแค่ prose: .d.ts ของ Svelte เอง declare innerWidth/innerHeight/ฯลฯ เป็น ReactiveValue<number | undefined> พร้อม doc comment “On the server it is undefined.” เหมือน MediaQuery พวกนี้อ่าน window ตอน construct/อ่านครั้งแรก — พิสูจน์ด้วย type signature เอง ไม่ใช่แค่ convention — โค้ดที่อ่าน .current ต้องจัดการเคส undefined ไม่งั้น svelte-check จะปฏิเสธ (ยืนยันข้างบนแล้ว: การละ fallback คือ error TS18048 “possibly undefined” จริง ไม่ใช่แค่ style nit)
เมื่อไร store ยังคุ้มค่าอยู่
หัวข้อที่มีชื่อว่า “เมื่อไร store ยังคุ้มค่าอยู่”ด้วย svelte/reactivity และ rune ที่มีให้ใช้ ให้เลือก writable/readable/derived ของ svelte/store เมื่อคุณต้องการcontractโดยเฉพาะ: interop กับ observable (RxJS และ library คล้ายกันส่ง object ที่มี subscribe method มาให้ ไม่ใช่ $state) หรือทำงานกับโค้ดที่ใช้ store อยู่แล้ว สำหรับ reactive collection และ push source ธรรมดา ๆ class ของ svelte/reactivity มักง่ายกว่า — ไม่มี $ ไม่มีพิธีการของ store พิสูจน์ข้างบนแล้วว่ามัน track ระดับ method ในจุดที่ $state บน Map/Set ธรรมดาทำไม่ได้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทั้งสองตระกูลแก้ปัญหา “แจ้ง dependent ตอนมีอะไรเปลี่ยน” เหมือนกัน แต่เชื่อมการแจ้งเตือนนั้นผ่านกลไกต่างกันสองแบบ กลไกของ store คือ subscribe contract — object เก็บ list ของ callback แล้วเรียกมัน $ คือน้ำตาลของ compiler ทับกลไกนั้น กลไกของ class svelte/reactivity คือ signal graph เดียวกับที่ rune ใช้ — การอ่าน tracked property/getter ลงทะเบียน dependency เหมือนอ่าน $state เป๊ะ ไม่มี subscription list เข้ามาเกี่ยวข้องเลย นี่คือเหตุผลที่ SvelteMap/MediaQuery/createSubscriber ไม่ต้องการ $ และ interop กับ $derived/$effect ได้อย่างไร้รอยต่อ ในขณะที่ store ต้องการสะพาน $ (หรือ fromStore บทถัดไป) เพื่อเข้าร่วม graph เดียวกัน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
writable/readable/derived (svelte/store) | Subscribe contract — interop กับ RxJS/observable ใช้ได้ในไฟล์ .js ใดก็ได้ | พิธีการมากกว่า ($, set/update); ไม่มี reactivity ระดับ method สำหรับ Map/Set |
SvelteMap/SvelteSet/SvelteDate/SvelteURL (svelte/reactivity) | Reactivity ระดับ method (.set(), .add()) พิสูจน์ข้างบนแล้ว; class ธรรมดา ไม่มี $ | ใช้ได้แค่ที่ rune ทำงาน (.svelte/.svelte.js/.svelte.ts); ไม่มี subscribe contract สำหรับ interop |
createSubscriber สำหรับ push source เอง | Lifecycle start/stop เดียวกับ readable แต่เชื่อมตรงเข้า rune tracking | ระดับล่างกว่า — คุณต้องเขียน getter และ class เอง |
svelte/reactivity/window | ไม่ต้องเขียน listener resize/scroll/online มือ | ไม่มีค่าที่ถูกต้องระหว่าง SSR — caveat hydration เดียวกับค่าที่มาจาก window ใด ๆ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
$state(new Map())ให้ Map ที่ reactive เหมือนที่$state({})ให้ object ที่ reactive” พิสูจน์แล้วว่าผิด: proxy ของ$stateไม่ instrument method ของMap/Setอย่าง.set()/.delete()— การ mutate เหล่านั้นมองไม่เห็นสำหรับ effect นอกจากคุณจะ reassign ทั้งMapหรือใช้SvelteMap/SvelteSetแทน - “start function ของ
readableรันครั้งเดียวต่อ subscriber และ stop รันครั้งเดียวต่อ unsubscribe” พิสูจน์แล้วว่าผิด (และพิสูจน์ไปแล้วในบท The Store Contract): start รันครั้งเดียวสำหรับ subscriber แรก stop รันครั้งเดียวตอนตัวสุดท้ายออกไป — ไม่ใช่จับคู่ 1:1 ต่อ subscriber - “
createSubscriberและ class ของsvelte/reactivityก็คือ store แค่เขียนต่างแบบ” พวกมันไม่มีsubscribemethod และไม่มี store contract เลย — มันเกี่ยวเข้ากับ rune dependency graph ตรง ๆfromStore/toStore(บทถัดไป) มีอยู่โดยเฉพาะเพื่อเชื่อมสองโลกนี้ เพราะมันไม่ได้ใช้แทนกันได้โดย default - “ค่าของ
svelte/reactivity/windowปลอดภัยที่จะอ่านระหว่าง server-side rendering” พวกมันอ่าน property จริงของwindowซึ่งไม่มีอยู่จริงบน server — เหมือนMediaQueryให้คาดหวัง hydration mismatch ที่เป็นไปได้ ไม่ใช่ค่าที่ถูกต้องแน่นอนจาก SSR
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อเจอ writable/readable/derived หรือ Map/Set ภายใต้ $state ให้เช็คสิ่งเหล่านี้:
MapหรือSetถูกห่อตรง ๆ ใน$state($state(new Map())) โดยเรียก.set()/.add()/.delete()บนมันและคาดว่า UI จะ update ไหม? พิสูจน์ข้างบนแล้ว: มันจะไม่ update นอกจากทั้ง collection ถูก reassignreadableที่เขียนมือห่อ WebSocket/interval/listener แทนที่ด้วยSvelteMap/SvelteSet/createSubscriberเพื่อลดพิธีการและไม่ต้องมี$ได้ไหม — หรือกลับกัน class ของsvelte/reactivityถูกใช้ในจุดที่โค้ดต้องการinteropกับ store จริง ๆ (RxJS, library ที่ใช้ store อยู่แล้ว) หรือเปล่า?writable(หรือ store ใด ๆ) ถูกห่อซ้ำสองใน$state—let store = $state(writable(0))— แทนที่จะเลือกระบบ reactivity เดียวหรือเปล่า? (identity ของ store ไม่เปลี่ยนตอนset/updateมันคือ object เดิมที่มีsubscribemethod เดิมตลอด ดังนั้นห่อมันใน$stateจึง track อะไรที่มีประโยชน์ไม่ได้เลย)- ค่าจาก
svelte/reactivity/window(หรือMediaQuery) ถูกอ่านระหว่าง server-side rendering ราวกับว่ามันสะท้อน browser state จริง โดยไม่มีแผนรองรับค่าที่เปลี่ยนตอน client hydrate หรือเปล่า?
🐛 หา bug ในโค้ดนี้
function createTagsStore() {let tags = $state(writable(new Set()));
return { add(tag) { $tags.update((set) => { set.add(tag); return set; }); }, get current() { return $tags; },};}เฉลย
writable(new Set()) เองเป็น store ที่ดีอยู่แล้ว — แต่การห่อตัว store object เองใน $state ($state(writable(...))) ผสมสองระบบ reactivity เข้าด้วยกันในแบบที่ track อะไรที่มีประโยชน์ไม่ได้เลย: identity ของ store ไม่เคยเปลี่ยนตอนคุณเรียก set/update บนมัน (มันคือ object เดิมที่มี subscribe method เดิมตลอด) ดังนั้น $state รอบมันจึงไม่มีการ reassign ให้สังเกตเห็นเลย แยกต่างหาก $tags (auto-subscription ที่มี $ นำหน้า) ถูกใช้กับตัวแปรชื่อ tags ไม่ใช่ store — น้ำตาลของ $ ของ Svelte อิงตามชื่อ (มัน subscribe กับสิ่งที่ tags ชี้ไปหา) ดังนั้นมันจะทำงานได้ก็ต่อเมื่อ tags เองเป็น store จริง ไม่ใช่เวอร์ชันที่ถูกห่อด้วย $state ทางแก้คือเลือกระบบเดียว: ทั้ง const tags = writable(new Set()) (store ธรรมดา ใช้ $tags/tags.update ไม่มี $state มาเกี่ยวข้องเลย) หรือทิ้ง store ไปใช้ const tags = new SvelteSet() จาก svelte/reactivity แทน ซึ่ง — พิสูจน์ข้างบนแล้ว — reactive ระดับ method (tags.add(...)) โดยไม่มี store ไม่มี $ และไม่มีความเสี่ยงห่อซ้ำสอง