Lifecycle
$effect ครอบคลุมงาน lifecycle ส่วนใหญ่
หัวข้อที่มีชื่อว่า “$effect ครอบคลุมงาน lifecycle ส่วนใหญ่”ใน Svelte 4 เราหยิบ lifecycle hook (onMount, beforeUpdate, afterUpdate) มาใช้กับอะไรก็ตามที่เกี่ยวกับ DOM หรือ timing ใน Svelte 5 $effect ครอบคลุมส่วนใหญ่ของสิ่งนั้น: $effect run หลัง DOM update, run ซ้ำเมื่อ dependency เปลี่ยน และ cleanup function ที่ return จัดการการ teardown
<script> let count = $state(0);
$effect(() => { document.title = `Count: ${count}`; // runs after mount and on every change return () => { document.title = 'App'; }; // cleanup on destroy / before re-run });</script>ดังนั้นก่อนหยิบ lifecycle function ให้ถามว่า effect เหมาะไหม บ่อยครั้งก็เหมาะ
onMount และ onDestroy
หัวข้อที่มีชื่อว่า “onMount และ onDestroy”lifecycle function สองตัวยังมีประโยชน์จริง:
onMount(fn)run ครั้งเดียวหลัง component render ลง DOM ครั้งแรก เฉพาะใน browser (ไม่เคยระหว่าง SSR) เป็นที่ที่เหมาะสำหรับ setup ที่ browser-only: วัด DOM, init canvas หรือ map library, เริ่ม interval ถ้าfnreturn function จะถูกเรียกตอน destroyonDestroy(fn)run เมื่อ component ถูกลบ — สำหรับ cleanup ที่ไม่ผูกกับ effect ตัวใดตัวหนึ่ง (unsubscribe, clear timer)
<script> import { onMount, onDestroy } from 'svelte';
onMount(() => { const chart = new Chart(canvasEl); // browser-only library return () => chart.destroy(); // cleanup on unmount });
onDestroy(() => console.log('gone'));</script>ความต่างสำคัญจาก $effect: onMount run ครั้งเดียวพอดี และ เฉพาะใน browser นั่นคือสิ่งที่คุณต้องการเป๊ะ ๆ สำหรับ initialization ครั้งเดียวแบบ browser-only effect ที่ไม่อ่าน reactive state ก็ run ครั้งเดียวเหมือนกัน แต่ onMount บอก intent ชัดและการันตีว่า run เฉพาะ browser
พิสูจน์ลำดับ: onMount run ก่อน $effect ตัวแรก
หัวข้อที่มีชื่อว่า “พิสูจน์ลำดับ: onMount run ก่อน $effect ตัวแรก”ทั้งสองอย่าง run ครั้งเดียวตอน render ครั้งแรก แต่ลำดับอาจไม่ตรงกับที่คุณเดา — callback ของ onMount run ก่อน แล้ว body ของ $effect ค่อย run:
<script lang="ts"> import { onMount } from 'svelte'; let { log }: { log: string[] } = $props();
log.push('script-top');
onMount(() => { log.push('onMount'); });
$effect(() => { log.push('effect'); });</script>
<p>ok</p>import { test, expect } from 'vitest';import { render } from '@testing-library/svelte';import OrderProbe from './OrderProbe.svelte';
test('order on first render: script body, then onMount, then $effect', () => { const log: string[] = []; render(OrderProbe, { props: { log } }); expect(log).toEqual(['script-top', 'onMount', 'effect']);});✓ order on first render: script body, then onMount, then $effectflowchart LR create["component ถูกสร้าง"] --> mount["onMount (browser, ครั้งเดียว)"] mount --> effects["$effect run หลัง DOM update"] effects --> effects effects --> destroy["onDestroy + cleanup ของ effect"]
tick: รอให้ DOM update
หัวข้อที่มีชื่อว่า “tick: รอให้ DOM update”Svelte batch การ update DOM เมื่อคุณเปลี่ยน state และต้องการให้ DOM สะท้อนการเปลี่ยนนั้น ก่อน บรรทัดถัดไป run (เพื่อวัด element หรือ focus input ที่เพิ่งแสดง) await tick() resolve เมื่อ pending changes ถูก apply แล้ว
<script> import { tick } from 'svelte';
async function addAndScroll() { items.push(newItem); await tick(); // wait for the new row to be in the DOM list.scrollTop = list.scrollHeight; }</script>flushSync: บังคับให้ effect run แบบ synchronous
หัวข้อที่มีชื่อว่า “flushSync: บังคับให้ effect run แบบ synchronous”mount()/hydrate() (imperative API — ใช้ bootstrap src/main.ts เวลาไม่ได้ใช้ SvelteKit เป็นตัวอย่าง) ไม่ synchronous เมื่อเทียบกับ effect: มัน return ก่อนที่ $effect ใด ๆ — รวมถึงตัวที่อยู่หลัง onMount — จะ run เลยด้วยซ้ำ effect ถูก schedule เป็น microtask flushSync() (import จาก 'svelte') บังคับให้ pending effect ทุกตัว run ทันที — มีประโยชน์ในการทดสอบ และเวลาไหนก็ตามที่ imperative code ต้องการให้ DOM/effect ตามทันตอนนี้เลย
<script lang="ts"> import { onMount } from 'svelte'; let { log }: { log: string[] } = $props(); onMount(() => { log.push('onMount'); });</script><p>ok</p>import { test, expect } from 'vitest';import { mount, flushSync, unmount } from 'svelte';import FlushDemo from './FlushDemo.svelte';
test('mount() does not run onMount synchronously; flushSync() forces it', () => { const target = document.createElement('div'); const log: string[] = []; const app = mount(FlushDemo, { target, props: { log } }); // mount() has already returned, but the onMount effect hasn't run yet. expect(log).toEqual([]); flushSync(); expect(log).toEqual(['onMount']); unmount(app);});✓ mount() does not run onMount synchronously; flushSync() forces itคู่มือ migrate ไป Svelte 5 พูดตรง ๆ ว่า: “things like onMount won’t have been called by the time the [mount] function returns… If you need that guarantee, call flushSync” component test ที่เขียนด้วย @testing-library/svelte ปกติไม่ต้องใช้ตัวนี้ — render() ของมัน flush ให้อยู่แล้ว — แต่การเรียก mount()/hydrate() แบบดิบต้องใช้
$effect cleanup ตอน unmount
หัวข้อที่มีชื่อว่า “$effect cleanup ตอน unmount”cleanup function ที่ effect return run ก่อน effect จะ run ซ้ำ และ run อีกครั้งเมื่อ component ที่เป็นเจ้าของมันถูกทำลาย — function ตัวเดียวกันจัดการทั้งสองกรณี
<script lang="ts"> let { log }: { log: string[] } = $props(); $effect(() => { log.push('effect-run'); return () => log.push('effect-cleanup'); });</script><p>ok</p>import { test, expect } from 'vitest';import { render } from '@testing-library/svelte';import { flushSync } from 'svelte';import CleanupDemo from './CleanupDemo.svelte';
test('$effect cleanup runs when the component unmounts', () => { const log: string[] = []; const { unmount } = render(CleanupDemo, { props: { log } }); flushSync(); expect(log).toEqual(['effect-run']); unmount(); expect(log).toEqual(['effect-run', 'effect-cleanup']);});✓ $effect cleanup runs when the component unmountsuntrack: อ่านค่าโดยไม่ผูกเป็น dependency
หัวข้อที่มีชื่อว่า “untrack: อ่านค่าโดยไม่ผูกเป็น dependency”$effect ผูกกับทุก reactive value ที่มันอ่านแบบ synchronous untrack(fn) (import จาก 'svelte') run fn โดยพัก dependency-tracking ไว้ชั่วคราว ดังนั้นค่าที่อ่านข้างในถูกใช้จริงแต่ไม่ทำให้ effect run ซ้ำเมื่อค่านั้นเปลี่ยน:
<script lang="ts"> import { untrack } from 'svelte'; let { log }: { log: string[] } = $props(); let a = $state(1); let b = $state(10);
$effect(() => { // `a` is a tracked dependency; `b` is read through untrack and is not. log.push(`a=${a} b=${untrack(() => b)}`); });</script><button data-testid="bump-a" onclick={() => a++}>bump a</button><button data-testid="bump-b" onclick={() => b++}>bump b</button>import { test, expect } from 'vitest';import { render, screen, fireEvent } from '@testing-library/svelte';import UntrackDemo from './UntrackDemo.svelte';
test('untrack exempts a value from being an effect dependency', async () => { const log: string[] = []; render(UntrackDemo, { props: { log } }); expect(log).toEqual(['a=1 b=10']);
// Changing `b` (read through untrack) must NOT re-run the effect. await fireEvent.click(screen.getByTestId('bump-b')); expect(log).toEqual(['a=1 b=10']);
// Changing `a` (tracked normally) DOES re-run it, reading the latest `b`. await fireEvent.click(screen.getByTestId('bump-a')); expect(log).toEqual(['a=1 b=10', 'a=2 b=11']);});✓ untrack exempts a value from being an effect dependencyuntrack มีประโยชน์ที่สุดข้างใน effect แนว lifecycle ที่ต้อง อ่าน ค่าเพื่อทำอะไรครั้งเดียว (เช่น log prop ปัจจุบันตอน mount) โดยไม่อยากให้ทุกครั้งที่ค่านั้นเปลี่ยนในอนาคตกลายเป็นการ run ซ้ำ
<svelte:boundary>: กัน error จาก render/effect และเนื้อหา pending แบบ async
หัวข้อที่มีชื่อว่า “<svelte:boundary>: กัน error จาก render/effect และเนื้อหา pending แบบ async”โดย default error ที่ throw ระหว่าง render (หรือใน effect) จะ unmount component tree ทั้งหมดที่อยู่รอบ ๆ — widget เดียวที่พังทำให้ทั้งหน้าว่างเปล่าได้ <svelte:boundary> กันความล้มเหลวนั้นไว้ใน subtree ของตัวเอง และแยกต่างหากสามารถแสดง snippet pending ระหว่างที่ await expression ข้างในกำลัง resolve
failed + reset(): กัน render error
หัวข้อที่มีชื่อว่า “failed + reset(): กัน render error”<script lang="ts"> let { shouldThrow, events }: { shouldThrow: boolean; events: string[] } = $props(); if (shouldThrow) { throw new Error('boom'); } events.push('rendered-ok');</script><p data-testid="child-ok">ok</p><script lang="ts"> import Flaky from './Flaky.svelte'; let { shouldThrow, events }: { shouldThrow: boolean; events: string[] } = $props();</script>
<svelte:boundary onerror={() => events.push('onerror')}> <Flaky {shouldThrow} {events} />
{#snippet failed(error: unknown, reset: () => void)} <p data-testid="fallback">{(error as Error).message}</p> <button data-testid="reset-btn" onclick={() => { shouldThrow = false; reset(); }}>retry</button> {/snippet}</svelte:boundary>import { test, expect } from 'vitest';import { render, screen, fireEvent } from '@testing-library/svelte';import BoundaryHost from './BoundaryHost.svelte';
test('the boundary contains the error, and reset() re-renders the child', async () => { const events: string[] = []; render(BoundaryHost, { props: { shouldThrow: true, events } }); // The throw in Flaky never reached the top of the app — it's contained here. expect(screen.getByTestId('fallback').textContent).toBe('boom'); expect(events).toEqual(['onerror']);
await fireEvent.click(screen.getByTestId('reset-btn')); expect(screen.getByTestId('child-ok')).toBeTruthy(); expect(events).toEqual(['onerror', 'rendered-ok']);});✓ the boundary contains the error, and reset() re-renders the childshouldThrow ตรงนี้เป็น prop ธรรมดาที่ host พลิกก่อนเรียก reset() — นั่นคือ pattern: แก้สิ่งที่ทำให้พังก่อน แล้วค่อยเรียก reset() ให้ boundary ลอง render children ใหม่ตั้งแต่ต้น (svelte-check จะ flag if (shouldThrow) ที่ top-level ของ Flaky ด้วย warning state_referenced_locally ซึ่งไม่เป็นอันตราย — มันสังเกตถูกแล้วว่าเช็คนี้ run แค่ครั้งเดียวตอน render ครั้งแรก ซึ่งตรงตามที่ตั้งใจ: ตัดสินว่า render ครั้งนี้จะ throw หรือไม่)
จุดที่ต้องระวังเรื่อง timing ของ reset()
หัวข้อที่มีชื่อว่า “จุดที่ต้องระวังเรื่อง timing ของ reset()”reset() เรียกแบบ synchronous ข้างใน onerror ไม่ได้ ตอนที่ boundary ยังจัดการ error ค้างอยู่ — Svelte throw error จริงถ้าคุณลอง:
<script lang="ts"> import Flaky from './Flaky.svelte'; let shouldThrow = $state(true); let events: string[] = [];</script><svelte:boundary onerror={(error, reset) => { shouldThrow = false; reset(); // BUG: called synchronously inside onerror}}> <Flaky {shouldThrow} {events} /></svelte:boundary>import { test, expect } from 'vitest';import { render } from '@testing-library/svelte';import ResetSyncHost from './ResetSyncHost.svelte';
test('calling reset() synchronously inside onerror throws', () => { expect(() => render(ResetSyncHost)).toThrowError(/svelte_boundary_reset_onerror/);});✓ calling reset() synchronously inside onerror throwserror จริง: svelte_boundary_reset_onerror — “A <svelte:boundary> reset function cannot be called while an error is still being handled” (svelte.dev/e/svelte_boundary_reset_onerror) ถ้าคุณแก้ error ได้ ข้างใน onerror เอง วิธีแก้คือ await tick() ก่อนเรียก reset() เพื่อให้ boundary settle ก่อน ไม่งั้นให้แก้สาเหตุที่อื่น (เช่น click ปุ่ม อย่างตัวอย่างด้านบน) แล้วค่อย reset จากตรงนั้น
pending: แสดง UI ระหว่างรอเนื้อหาที่ await resolve
หัวข้อที่มีชื่อว่า “pending: แสดง UI ระหว่างรอเนื้อหาที่ await resolve”จับคู่กับ await expression ตรง ๆ ใน template (async Svelte — ดูบท async ของโมดูล reactivity — ต้องเปิด compilerOptions.experimental.async: true) snippet pending ของ boundary จะแสดงจนกว่า await ทุกตัวข้างในจะ resolve ครั้งแรก มันไม่โผล่ซ้ำสำหรับการ update ครั้งต่อไป — นั่นคือหน้าที่ของ $effect.pending()
<script lang="ts"> async function loadMessage() { await new Promise((r) => setTimeout(r, 20)); return 'loaded!'; }</script>
<svelte:boundary> <p data-testid="msg">{await loadMessage()}</p>
{#snippet pending()} <p data-testid="loading">loading…</p> {/snippet}</svelte:boundary>import { test, expect } from 'vitest';import { render, screen, waitFor } from '@testing-library/svelte';import PendingDemo from './PendingDemo.svelte';
test('the pending snippet shows first, then the resolved content replaces it', async () => { render(PendingDemo); expect(screen.getByTestId('loading')).toBeTruthy(); await waitFor(() => expect(screen.getByTestId('msg').textContent).toBe('loaded!')); expect(screen.queryByTestId('loading')).toBeNull();});✓ the pending snippet shows first, then the resolved content replaces itMental Model
หัวข้อที่มีชื่อว่า “Mental Model”มองอายุของ component เป็นสามช่วง: setup (body ของ <script> run จากบนลงล่างครั้งเดียว), mounted (onMount fire แล้ว effect เริ่ม run และ run ซ้ำตาม dependency เปลี่ยน), และ teardown (effect cleanup run แล้วค่อย onDestroy) <svelte:boundary> ไม่ได้เปลี่ยนภาพนี้ — มันแค่กั้นกำแพงรอบ subtree เพื่อไม่ให้ความล้มเหลวข้างในลาม teardown ออกไปนอกกำแพง flushSync กับ tick พูดถึงข้อเท็จจริงเดียวกัน: DOM update กับ effect ถูก schedule ไว้ ไม่ได้เกิดทันที — tick รอหนึ่งรอบจากข้างในของ component ที่กำลัง run อยู่ flushSync บังคับให้ทุกอย่างที่ pending เกิดขึ้นตอนนี้เลย มีประโยชน์จากนอก component ปกติ (test, ตัวที่เรียก mount() แบบ imperative)
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ได้อะไร | ประโยชน์ | ต้นทุน |
|---|---|---|
onMount แทน $effect เปล่า ๆ | บอก intent “browser-only, ครั้งเดียว” ชัดเจน; การันตีลำดับก่อน $effect ตัวแรก | run ซ้ำไม่ได้ — ใช้ผิดที่ถ้าต้องขึ้นกับ dependency |
<svelte:boundary failed> | widget เดียวที่พัง ไม่ unmount ทั้งแอป | ต้องออกแบบทาง recover จริง — reset() แค่ลองใหม่ ไม่ได้แก้สาเหตุ |
<svelte:boundary pending> | loading UI แบบ declarative สำหรับเนื้อหาที่ await ใน template | ยัง experimental (experimental.async); ครอบคลุมแค่การ resolve ครั้งแรก ไม่ใช่ update ครั้งต่อไป (ตรงนั้นใช้ $effect.pending()) |
untrack ข้างใน effect | อ่านค่าโดยไม่ให้ effect subscribe เกินจำเป็น | ใช้ผิดได้ง่าย — untrack ค่าที่ ควร trigger การ run ซ้ำ จะทำให้ reactivity พังแบบเงียบ ๆ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
onMountกับ$effectตัวแรก run ตามลำดับที่เดา หรือ run พร้อมกัน” พิสูจน์แล้วด้านบน: callback ของonMountrun ก่อน body ของ$effectเสมอตอน render ครั้งแรก - “
mount()เสร็จสมบูรณ์ก็ต่อเมื่อonMountrun แล้ว” ไม่จริง —mount()/hydrate()return ก่อน effect จะ run เลยด้วยซ้ำflushSync()คือสิ่งที่บังคับให้ตามทัน พิสูจน์แล้วด้านบน - “เรียก
reset()ข้างในonerrorแล้ว boundary หายพังทันที” ไม่ synchronous — Svelte throwsvelte_boundary_reset_onerrorถ้าลอง พิสูจน์แล้วด้านบน แก้สาเหตุที่อื่น (หรือawait tick()ก่อน) แล้วค่อย reset จากตรงนั้น - “snippet
pendingของ<svelte:boundary>โผล่ทุกครั้งที่ค่าที่ await ข้างในอัปเดต” มันโผล่แค่ตอน resolve ครั้งแรก เท่านั้น การ update แบบ async ครั้งต่อไปต้องใช้$effect.pending()แทน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เวลารีวิวโค้ด lifecycle ที่ generate มา ให้เช็ค:
- ค่าที่ควรคำนวณใหม่ทุกครั้งที่ prop/state เปลี่ยน ถูกคำนวณข้างใน
onMountแทน$derivedหรือเปล่า?onMountrun แค่ครั้งเดียว — มันจะ stale ทันทีที่ source value เปลี่ยน $effectอ่านค่าตัวหนึ่งแค่เพื่อ log หรือวัดครั้งเดียว โดยไม่ห่อการอ่านนั้นด้วยuntrackทำให้ run ซ้ำโดยไม่จำเป็นทุกครั้งที่ค่าที่ไม่เกี่ยวข้องนั้นเปลี่ยนหรือเปล่า?reset()ถูกเรียกแบบ synchronous ข้างในonerror(เป็น error จริงที่ throw พิสูจน์แล้วด้านบน) แทนที่จะเรียกจาก action แยก (ปุ่ม, retry-after-fix) หรืออยู่หลังawait tick()หรือเปล่า?- โค้ดเรียก
mount()/hydrate()แล้ว assert ผลข้างเคียงของonMount(DOM เปลี่ยน, element ถูกแทรก) ทันทีโดยไม่มีflushSync()คั่นกลางหรือเปล่า? <svelte:boundary>ถูกเพิ่มโดยไม่มีfailed/onerrorเลยหรือเปล่า? — กัน error แบบเงียบ ๆ ไม่มี visibility เลย ซึ่งเป็น blind spot แบบเดียวกับที่ boundary ควรจะแก้
🐛 หา bug ในโค้ดนี้
<script>import { onMount } from 'svelte';
let { userId } = $props();let displayName = $state('');
onMount(() => { displayName = formatName(userId); // computed once, on mount});</script>
<p>{displayName}</p>เฉลย
displayName ถูกคำนวณ ครั้งเดียว ข้างใน onMount — แต่ userId เป็น prop และ prop เปลี่ยนได้ตลอดอายุของ component (parent ส่ง userId ตัวใหม่มาทีหลังได้) เพราะ computation นี้ run แค่ตอน mount displayName จะ stale เงียบ ๆ ทันทีที่ userId เปลี่ยน ไม่มีอะไร run ซ้ำให้เลย วิธีแก้คือ let displayName = $derived(formatName(userId)); — derived คำนวณใหม่อัตโนมัติทุกครั้งที่ userId เปลี่ยน ซึ่งตรงกับเคส “ค่าที่คำนวณจาก reactive state อื่น” ที่ onMount ไม่เคยเป็นเครื่องมือที่ถูกต้อง (พิสูจน์แล้วด้านบน: onMount run แค่ครั้งเดียวพอดี)