ข้ามไปยังเนื้อหา

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 เหมาะไหม บ่อยครั้งก็เหมาะ

lifecycle function สองตัวยังมีประโยชน์จริง:

  • onMount(fn) run ครั้งเดียวหลัง component render ลง DOM ครั้งแรก เฉพาะใน browser (ไม่เคยระหว่าง SSR) เป็นที่ที่เหมาะสำหรับ setup ที่ browser-only: วัด DOM, init canvas หรือ map library, เริ่ม interval ถ้า fn return function จะถูกเรียกตอน destroy
  • onDestroy(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

ทั้งสองอย่าง run ครั้งเดียวตอน render ครั้งแรก แต่ลำดับอาจไม่ตรงกับที่คุณเดา — callback ของ onMount run ก่อน แล้ว body ของ $effect ค่อย run:

src/lib/OrderProbe.svelte
<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>
src/lib/order.test.ts
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 $effect
flowchart LR
  create["component ถูกสร้าง"] --> mount["onMount (browser, ครั้งเดียว)"]
  mount --> effects["$effect run หลัง DOM update"]
  effects --> effects
  effects --> destroy["onDestroy + cleanup ของ effect"]
จังหวะ lifecycle ของ component

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>

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 ตามทันตอนนี้เลย

src/lib/FlushDemo.svelte
<script lang="ts">
import { onMount } from 'svelte';
let { log }: { log: string[] } = $props();
onMount(() => { log.push('onMount'); });
</script>
<p>ok</p>
src/lib/flush.test.ts
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() แบบดิบต้องใช้

cleanup function ที่ effect return run ก่อน effect จะ run ซ้ำ และ run อีกครั้งเมื่อ component ที่เป็นเจ้าของมันถูกทำลาย — function ตัวเดียวกันจัดการทั้งสองกรณี

src/lib/CleanupDemo.svelte
<script lang="ts">
let { log }: { log: string[] } = $props();
$effect(() => {
log.push('effect-run');
return () => log.push('effect-cleanup');
});
</script>
<p>ok</p>
src/lib/cleanup.test.ts
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 unmounts

$effect ผูกกับทุก reactive value ที่มันอ่านแบบ synchronous untrack(fn) (import จาก 'svelte') run fn โดยพัก dependency-tracking ไว้ชั่วคราว ดังนั้นค่าที่อ่านข้างในถูกใช้จริงแต่ไม่ทำให้ effect run ซ้ำเมื่อค่านั้นเปลี่ยน:

src/lib/UntrackDemo.svelte
<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>
src/lib/untrack.test.ts
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 dependency

untrack มีประโยชน์ที่สุดข้างใน effect แนว lifecycle ที่ต้อง อ่าน ค่าเพื่อทำอะไรครั้งเดียว (เช่น log prop ปัจจุบันตอน mount) โดยไม่อยากให้ทุกครั้งที่ค่านั้นเปลี่ยนในอนาคตกลายเป็นการ run ซ้ำ

โดย default error ที่ throw ระหว่าง render (หรือใน effect) จะ unmount component tree ทั้งหมดที่อยู่รอบ ๆ — widget เดียวที่พังทำให้ทั้งหน้าว่างเปล่าได้ <svelte:boundary> กันความล้มเหลวนั้นไว้ใน subtree ของตัวเอง และแยกต่างหากสามารถแสดง snippet pending ระหว่างที่ await expression ข้างในกำลัง resolve

src/lib/Flaky.svelte
<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>
src/lib/BoundaryHost.svelte
<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>
src/lib/boundary.test.ts
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 child

shouldThrow ตรงนี้เป็น prop ธรรมดาที่ host พลิกก่อนเรียก reset() — นั่นคือ pattern: แก้สิ่งที่ทำให้พังก่อน แล้วค่อยเรียก reset() ให้ boundary ลอง render children ใหม่ตั้งแต่ต้น (svelte-check จะ flag if (shouldThrow) ที่ top-level ของ Flaky ด้วย warning state_referenced_locally ซึ่งไม่เป็นอันตราย — มันสังเกตถูกแล้วว่าเช็คนี้ run แค่ครั้งเดียวตอน render ครั้งแรก ซึ่งตรงตามที่ตั้งใจ: ตัดสินว่า render ครั้งนี้จะ throw หรือไม่)

reset() เรียกแบบ synchronous ข้างใน onerror ไม่ได้ ตอนที่ boundary ยังจัดการ error ค้างอยู่ — Svelte throw error จริงถ้าคุณลอง:

src/lib/ResetSyncHost.svelte
<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>
src/lib/resetsync.test.ts
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 throws

error จริง: 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 จากตรงนั้น

จับคู่กับ await expression ตรง ๆ ใน template (async Svelte — ดูบท async ของโมดูล reactivity — ต้องเปิด compilerOptions.experimental.async: true) snippet pending ของ boundary จะแสดงจนกว่า await ทุกตัวข้างในจะ resolve ครั้งแรก มันไม่โผล่ซ้ำสำหรับการ update ครั้งต่อไป — นั่นคือหน้าที่ของ $effect.pending()

src/lib/PendingDemo.svelte
<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>
src/lib/pending.test.ts
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 it

มองอายุของ 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 ของ onMount run ก่อน body ของ $effect เสมอตอน render ครั้งแรก
  • “mount() เสร็จสมบูรณ์ก็ต่อเมื่อ onMount run แล้ว” ไม่จริง — mount()/hydrate() return ก่อน effect จะ run เลยด้วยซ้ำ flushSync() คือสิ่งที่บังคับให้ตามทัน พิสูจน์แล้วด้านบน
  • “เรียก reset() ข้างใน onerror แล้ว boundary หายพังทันที” ไม่ synchronous — Svelte throw svelte_boundary_reset_onerror ถ้าลอง พิสูจน์แล้วด้านบน แก้สาเหตุที่อื่น (หรือ await tick() ก่อน) แล้วค่อย reset จากตรงนั้น
  • “snippet pending ของ <svelte:boundary> โผล่ทุกครั้งที่ค่าที่ await ข้างในอัปเดต” มันโผล่แค่ตอน resolve ครั้งแรก เท่านั้น การ update แบบ async ครั้งต่อไปต้องใช้ $effect.pending() แทน

เวลารีวิวโค้ด lifecycle ที่ generate มา ให้เช็ค:

  • ค่าที่ควรคำนวณใหม่ทุกครั้งที่ prop/state เปลี่ยน ถูกคำนวณข้างใน onMount แทน $derived หรือเปล่า? onMount run แค่ครั้งเดียว — มันจะ 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 แค่ครั้งเดียวพอดี)

ใน Svelte 5 อะไรครอบคลุมเคส lifecycle-hook เดิมส่วนใหญ่?
ตอน render ครั้งแรก อะไร run ก่อนกัน?
ทันทีที่ mount() return แล้ว onMount run หรือยัง?
const app = mount(App, { target });
console.log(onMountRan); // ?
untrack(() => b) ข้างใน $effect ทำอะไร?
เกิดอะไรขึ้นตรงนี้?
<svelte:boundary onerror={(error, reset) => {
  fixTheError();
  reset(); // called synchronously
}}>
snippet pending บน <svelte:boundary> แสดงอะไร?