The $effect Rune
Effect sync กับโลกภายนอก
หัวข้อที่มีชื่อว่า “Effect sync กับโลกภายนอก”$effect รันฟังก์ชันหลัง DOM update แล้ว และรันใหม่ทุกครั้งที่ค่า reactive ที่มันอ่านเปลี่ยน มันมีไว้สำหรับ side effect — sync state ของ Svelte กับสิ่งที่อยู่นอก Svelte: DOM ตรง ๆ, library ภายนอก, subscription, timer, logging, localStorage
<script> let count = $state(0);
$effect(() => { // Runs after render, and again whenever count changes. document.title = `Count: ${count}`; });</script>Dependency ถูก track อัตโนมัติ เหมือน $derived ทุกประการ — effect อ่าน count มันจึงรันใหม่ตอน count เปลี่ยน ไม่ต้องมี dependency array
คอมไพล์ effect ตัวเดียวกันนี้ด้านล่างแล้วสังเกต $.user_effect(...) ในผลลัพธ์ — compiler แยก tag $effect ที่คุณเขียนเองออกจาก effect ภายในของ template (ตัวที่คอยซิงก์ text node เฉย ๆ) อย่างชัดเจน
<script> // English comments: keep this pinned to Svelte 5 runes mode. let count = $state(0);
$effect(() => { console.log(count); });</script>
<button onclick={() => count++}>{count}</button> Dependency คือสิ่งที่การรันล่าสุดอ่านพอดี
หัวข้อที่มีชื่อว่า “Dependency คือสิ่งที่การรันล่าสุดอ่านพอดี”รายการ dependency ของ effect ไม่ได้ถูกกำหนดตายตัวตอนประกาศ — มันถูกคำนวณใหม่ทุกครั้งที่ effect รัน จากสิ่งที่มันอ่านจริง ๆ ในรอบนั้น ถ้า effect อ่านค่าใดค่าหนึ่งจากสองค่าแบบมีเงื่อนไข การพลิก condition จะเปลี่ยน dependency set ของมันในรอบถัดไป
<script> let useA = $state(true); let a = $state('a1'); let b = $state('b1');
$effect(() => { console.log(useA ? a : b); // only ONE of a/b is a dependency, depending on useA });</script>พิสูจน์แล้ว — effect ไม่สนใจการเปลี่ยนของ branch ที่มันไม่ได้อ่านในการรันล่าสุด และจับ branch อีกฝั่งทันทีที่ condition พลิก:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
test('an effect\'s dependencies are exactly what it read on its LAST run, not a fixed list', () => { const runs: string[] = []; const destroy = $effect.root(() => { let useA = $state(true); let a = $state('a1'); let b = $state('b1');
$effect(() => { runs.push(useA ? a : b); });
flushSync(); expect(runs).toEqual(['a1']); // deps so far: useA, a
b = 'b2'; // NOT read on the last run — must NOT re-run the effect flushSync(); expect(runs).toEqual(['a1']);
a = 'a2'; // WAS read on the last run — must re-run flushSync(); expect(runs).toEqual(['a1', 'a2']);
// Flip the branch: now the effect reads `b` instead of `a`. useA = false; flushSync(); expect(runs).toEqual(['a1', 'a2', 'b2']); // picks up the CURRENT value of b (set earlier while untracked)
a = 'a3'; // `a` is no longer a dependency after the flip — must NOT re-run flushSync(); expect(runs).toEqual(['a1', 'a2', 'b2']);
b = 'b3'; // `b` IS now a dependency — must re-run flushSync(); expect(runs).toEqual(['a1', 'a2', 'b2', 'b3']); }); destroy();});✓ an effect's dependencies are exactly what it read on its LAST run, not a fixed list
Test Files 1 passed (1) Tests 1 passed (1)นี่คือเหตุผลที่ “แค่เพิ่มมันเข้า deps” ไม่ค่อย make sense เป็น mental model ใน Svelte แบบที่มันเป็นกับ array ชัดเจนของ React — ไม่มี array ให้แก้ dependency set คือผลลัพธ์ live ของ code path ที่ effect เดินจริง ๆ ในรอบล่าสุด
Cleanup ที่พิสูจน์แล้ว
หัวข้อที่มีชื่อว่า “Cleanup ที่พิสูจน์แล้ว”ถ้า effect ตั้งค่าอะไรที่ต้องรื้อทิ้ง — interval, event listener, subscription — ให้ return cleanup function Svelte รันมันก่อน effect รันใหม่และตอน component (หรือ root) ถูกทำลาย
<script> let seconds = $state(0);
$effect(() => { const id = setInterval(() => seconds++, 1000); return () => clearInterval(id); // cleanup: runs before re-run and on destroy });</script>พิสูจน์แล้ว — cleanup ของรอบเก่าเกิดขึ้นก่อนรอบใหม่ของ effect เดียวกันเสมอ:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
test('cleanup for the OLD run always happens before the new run for the SAME effect', () => { const log: string[] = []; const destroy = $effect.root(() => { let id = $state(1); $effect(() => { const current = id; log.push(`setup ${current}`); return () => log.push(`cleanup ${current}`); }); flushSync(); id = 2; flushSync(); id = 3; flushSync(); }); destroy(); expect(log).toEqual(['setup 1', 'cleanup 1', 'setup 2', 'cleanup 2', 'setup 3', 'cleanup 3']);});✓ cleanup for the OLD run always happens before the new run for the SAME effect
Test Files 1 passed (1) Tests 1 passed (1)คำถามที่ไม่ชัดเจนนัก: เมื่อ effect ที่เป็น sibling กันหลายตัวถูกทำลายพร้อมกัน (scope ทั้งหมดถูกทำลาย) มันเก็บกวาดตามลำดับไหน? หลายคนอาจสมมติว่าเป็น “stack” (สร้างหลังสุด ทำลายก่อน) แบบระบบ disposal ทั่วไป — แต่ไม่ใช่แบบนั้น:
test('sibling effects in the same scope clean up in THE SAME order they were created (not reversed)', () => { const log: string[] = []; const destroy = $effect.root(() => { $effect(() => { log.push('setup A'); return () => log.push('cleanup A'); }); $effect(() => { log.push('setup B'); return () => log.push('cleanup B'); }); $effect(() => { log.push('setup C'); return () => log.push('cleanup C'); }); flushSync(); }); log.length = 0; destroy(); // tear down the whole root — all three effects clean up // Empirically: creation order A, B, C -> cleanup order A, B, C. Svelte does // NOT reverse sibling effect cleanup like a LIFO stack/disposer pattern. expect(log).toEqual(['cleanup A', 'cleanup B', 'cleanup C']);});✓ sibling effects in the same scope clean up in THE SAME order they were created (not reversed)
Test Files 1 passed (1) Tests 1 passed (1)อย่าสมมติลำดับ LIFO สำหรับ sibling effect — ถ้า cleanup logic ของคุณพึ่งพาลำดับสัมพัทธ์ระหว่าง effect สองตัว ให้ตรวจสอบด้วย test แบบข้างบน
untrack: พิสูจน์แล้ว
หัวข้อที่มีชื่อว่า “untrack: พิสูจน์แล้ว”untrack (แนะนำในบท Advanced Runes) ให้ effect อ่านค่าโดยไม่เพิ่มมันเป็น dependency ที่นี่พิสูจน์แบบครบวงจร รวมถึงเคส “control” ที่โชว์ว่าถ้าไม่ใช้มันจะเกิดอะไร:
import { flushSync, untrack } from 'svelte';import { expect, test } from 'vitest';
test('untrack lets an effect read a value without adding it as a dependency', () => { const runs: number[] = []; const destroy = $effect.root(() => { let count = $state(0); let verbose = $state(false);
$effect(() => { const isVerbose = untrack(() => verbose); // read, but NOT tracked runs.push(count); void isVerbose; });
flushSync(); expect(runs).toEqual([0]);
verbose = true; // read through untrack — must NOT trigger a re-run flushSync(); expect(runs).toEqual([0]);
count = 1; // a real, tracked dependency — must trigger a re-run flushSync(); expect(runs).toEqual([0, 1]); }); destroy();});
test('control: without untrack, reading verbose directly DOES make it a dependency', () => { const runs: string[] = []; const destroy = $effect.root(() => { let count = $state(0); let verbose = $state(false);
$effect(() => { runs.push(`${count}:${verbose}`); // direct read — tracked });
flushSync(); expect(runs).toEqual(['0:false']);
verbose = true; // now this alone re-runs the effect, because it was read directly flushSync(); expect(runs).toEqual(['0:false', '0:true']); }); destroy();});✓ untrack lets an effect read a value without adding it as a dependency✓ control: without untrack, reading verbose directly DOES make it a dependency
Test Files 1 passed (1) Tests 2 passed (2)$effect.pre
หัวข้อที่มีชื่อว่า “$effect.pre”$effect รันหลัง DOM update บางครั้งคุณต้องอ่าน DOM ก่อนมัน update — เช่น เก็บตำแหน่ง scroll ก่อนเนื้อหาใหม่จะดันมัน $effect.pre รันก่อน DOM update แทน ด้วยกฎ tracking, dependency และ cleanup เดียวกับที่พิสูจน์ไว้ข้างบน
<script> let messages = $state([]);
$effect.pre(() => { messages.length; // track the dependency // read scroll position BEFORE the DOM updates with new messages });</script>บทพิสูจน์: $effect เทียบ $derived สำหรับ sync state
หัวข้อที่มีชื่อว่า “บทพิสูจน์: $effect เทียบ $derived สำหรับ sync state”บท $derived Rune เตือนว่าการ mirror ค่าที่คำนวณได้ลง $state ผ่าน effect เป็น anti-pattern นี่คือบทพิสูจน์ที่เป็นรูปธรรม — ค่าที่ mirror ล้าหลังจริง วัดได้ ชั่วขณะหนึ่ง ในแบบที่ $derived ไม่มีวันเป็น:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
test('anti-pattern proven: an $effect that mirrors state into another $state variable LAGS by a flush', () => { const destroy = $effect.root(() => { let count = $state(2); let double = $state(0); // ❌ mirrored via effect, not derived
$effect(() => { double = count * 2; });
flushSync(); // let the initial effect run expect(double).toBe(4);
count = 3; // Read `double` SYNCHRONOUSLY, before flushSync — the effect has not run yet. expect(double).toBe(4); // ❌ STALE: still the old value, one write behind flushSync(); expect(double).toBe(6); // only correct AFTER an explicit flush }); destroy();});
test('the fix: $derived recomputes synchronously on read — no lag, no flush needed', () => { const destroy = $effect.root(() => { let count = $state(2); let double = $derived(count * 2); // ✅ derived, not mirrored via effect
expect(double).toBe(4); count = 3; // No flushSync at all — $derived is pulled fresh the instant it's read. expect(double).toBe(6); // ✅ always correct, synchronously }); destroy();});✓ anti-pattern proven: an $effect that mirrors state into another $state variable LAGS by a flush✓ the fix: $derived recomputes synchronously on read — no lag, no flush needed
Test Files 1 passed (1) Tests 2 passed (2)นี่ไม่ใช่แค่ความชอบด้าน style เชิงทฤษฎี — โค้ดไหนก็ตามที่อ่านค่า $state ที่ mirror ไว้แบบ synchronous ทันทีหลังการเขียนที่ควรจะเปลี่ยนมัน — เช่น click handler ที่ update count แล้วเช็ค double ทันที — จะเห็นค่าเก่ากับ pattern ที่ mirror ผ่าน effect และเห็นค่าถูกต้องกับ $derived
คุณอาจไม่ต้องใช้ effect เลย
หัวข้อที่มีชื่อว่า “คุณอาจไม่ต้องใช้ effect เลย”นี่คือคำแนะนำที่สำคัญที่สุดในโมดูลนี้ Effect เป็น escape hatch และการหยิบมันมาจัดการ state ภายใน คือความผิดพลาดที่พบบ่อยที่สุดของ Svelte 5 ก่อนเขียน effect ให้ถามตัวเอง:
- กำลังคำนวณค่าจาก state? ใช้
$derivedไม่ใช่ effect ที่ assign state — พิสูจน์แล้วข้างบนว่าทั้งช้ากว่า (flush เกิน) และผิดชั่วขณะ - กำลังตอบสนอง user action? ทำงานใน event handler (
onclick) ไม่ใช่ใน effect ที่คอยดูตัวแปร - กำลัง mirror prop ลง state? ปกติแล้วเป็นกลิ่นของ design ที่ไม่ดี — derive จาก prop แทน
flowchart TB q["Need to react to state?"] --> derive["Computing a value? → $derived"] q --> handler["From a user action? → event handler"] q --> effect["Syncing with something OUTSIDE Svelte? → $effect"]
Effect เป็นเครื่องมือที่ถูกต้องก็ต่อเมื่อคุณเชื่อมกับสิ่งที่ Svelte ควบคุมไม่ได้ — document title, canvas, websocket, localStorage, การเรียก analytics ถ้างานยังอยู่ในโลก reactive ของ Svelte rune อย่าง $derived มักเหมาะกว่าเกือบทุกครั้ง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Effect ไม่ใช่ lifecycle hook ที่ผูกกับการเกิดและตายของ component — มันคือ loop การ sync ที่ dependency set ของมันถูกคำนวณใหม่ทุกรอบจาก code path ที่มันเดินจริง ๆ Mount กับ unmount ก็แค่ setup แรกและ cleanup สุดท้ายของ loop เดียวกันนั้น เพราะ dependency set คือ “สิ่งที่เกิดขึ้นครั้งล่าสุด ไม่ใช่รายการตายตัว” การอ่านแบบมีเงื่อนไขใน effect จึงเปลี่ยนพฤติกรรมของมันจริง ๆ ในการเปลี่ยนค่าครั้งถัดไป — พิสูจน์แล้วข้างบนด้วยการพลิก branch
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
$derived แทน $effect สำหรับค่าคำนวณ | ไม่มีการล้าหลัง: ถูกต้องทันทีที่อ่าน ไม่ต้อง flush | ไม่มีข้อเสียเมื่อค่าคือฟังก์ชันบริสุทธิ์ของ state อื่น |
$effect.pre แทน $effect | อ่าน DOM ก่อน update ได้ (ตำแหน่ง scroll, การวัดขนาด) | แทบไม่ค่อยจำเป็น ส่วนใหญ่ต้องการ $effect แบบหลัง update |
untrack ใน effect | อ่านค่าที่เปลี่ยนบ่อยโดยไม่ trigger การรันซ้ำเกินจำเป็น | ใช้ผิดง่ายจนไปปิดเสียง dependency จริง — ตรวจด้วย test แบบข้างบน |
| พึ่งพาลำดับ cleanup ของ sibling effect | — | อย่าทำ — พิสูจน์แล้วว่ามันคือลำดับสร้าง ไม่ใช่ LIFO อย่าสร้าง logic ที่สมมติเป็นอย่างอื่นโดยไม่ตรวจก่อน |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “Dependency ของ effect ถูกกำหนดตายตัวครั้งเดียวจากการรันครั้งแรก” พิสูจน์แล้วว่าไม่จริง: การอ่านแบบมีเงื่อนไขเปลี่ยน dependency set ทุกรอบถัดไป ตาม branch ที่เดินจริง
- “Sibling effect เก็บกวาดแบบย้อนกลับ (LIFO) ตอน scope ถูกทำลาย” พิสูจน์แล้วว่าไม่จริง: cleanup รันตามลำดับเดียวกันกับการสร้าง (A, B, C) ไม่ใช่ย้อนกลับ
- “การ mirror state ที่ derive ได้ลง
$stateผ่าน$effectเป็นแค่ความต่างเชิง style” พิสูจน์แล้วว่าไม่จริง: ค่าที่ mirror ล้าหลังวัดได้จริงในช่วงระหว่างการเขียน state กับ flush ถัดไป — เป็นบั๊กจริงกับโค้ดใดก็ตามที่อ่านมันแบบ synchronous ในช่วงนั้น - “
untrackปิด reactivity ทั้ง effect” มันปิดแค่ค่าเฉพาะที่อ่านใน callback ของมันไม่ให้กลายเป็น dependency — ทุกอย่างอื่นที่ effect อ่านตรง ๆ ยัง track อยู่เหมือนเดิม ตามที่ control test ข้างบนแสดง
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ component ที่ AI เขียนใช้ $effect ให้เช็คสิ่งเหล่านี้ก่อนเชื่อมัน:
- effect assign ค่าให้
$stateแยกต่างหากที่จริง ๆ ทำเป็น$derivedตรง ๆ ได้เลยหรือเปล่า? นี่คือ anti-pattern ที่พบบ่อยที่สุดของ Svelte 5 และมันล้าหลังพิสูจน์ได้ชั่วขณะจริง ๆ ไม่ใช่แค่ “ไม่ idiomatic เฉย ๆ” - การอ่านแบบมีเงื่อนไขใน effect (
flag ? a : b) เป็นความตั้งใจหรือเปล่า — ผู้เขียนเข้าใจไหมว่ามีแค่ branch ที่เดินจริงเท่านั้นที่กลายเป็น dependency ในรอบนั้น? - effect ที่ตั้ง interval, listener หรือ subscription return cleanup function หรือเปล่า — หรือมันจะ leak ทุกครั้งที่รันใหม่และตอน unmount?
untrackถูกใช้อ่านค่าที่ผู้เขียนต้องการให้ react จริง ๆ ปิดเสียง dependency จริง แทนที่จะเป็นตัวที่ตั้งใจ exclude หรือเปล่า?- Cleanup logic ของ effect หลายตัวสมมติลำดับสัมพัทธ์เฉพาะระหว่างกันโดยไม่ได้ตรวจสอบลำดับนั้นหรือเปล่า?
🐛 หา bug ในโค้ดนี้
function PriceDisplay({ price, taxRate }) {let total = $state(0);
// "Keep total in sync with price and taxRate"$effect(() => { total = price * (1 + taxRate);});
return { get total() { return total; },};}
// Usage:const display = PriceDisplay({ price: $state(100), taxRate: $state(0.1) });function onPriceChange(newPrice) {display.price = newPrice;console.log('total right after price change:', display.total); // read immediately}เฉลย
total ถูก mirror จาก price/taxRate ผ่าน $effect แทนที่จะเป็น $derived — มัน update แค่ตอน flush ถัดไป ไม่ใช่แบบ synchronous console.log('total right after price change:', display.total) รันทันทีหลัง display.price = newPrice ก่อนที่ Svelte จะ flush effect ที่ค้างอยู่ ดังนั้นมัน log total ที่ล้าหลังซึ่งคำนวณจาก price เก่า พิสูจน์ในโปรบแล้ว: การอ่านค่าที่ mirror ผ่าน effect แบบ synchronous ทันทีหลังการเขียนที่ trigger มันคืนค่าเก่า (4 ไม่ใช่ 6 หลัง count เปลี่ยนจาก 2 เป็น 3) — มันจะถูกต้องก็ต่อเมื่อมี flushSync() ชัดเจน (หรือในการใช้งานจริง หลัง microtask/render ถัดไปของ browser) ทางแก้คือ let total = $derived(price * (1 + taxRate)): $derived คำนวณใหม่ทันทีที่ถูกอ่าน ไม่มีการล้าหลัง และไม่ต้องมี $state ตัวเกิน