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

Actions

บางครั้งคุณต้องการเข้าถึง DOM node แบบ imperative — เพื่อ init charting library, trap focus, ตั้ง tooltip หรือ detect click นอก element ตั้งแต่ Svelte 5.29 เครื่องมือสำหรับสิ่งนี้คือ attachment: ฟังก์ชันที่ส่งด้วย syntax {@attach ...} ซึ่งรับ element และอาจ return cleanup function

src/lib/LogAttachment.svelte
<script lang="ts">
/** @type {import('svelte/attachments').Attachment} */
function logMount(element: Element) {
console.log(element.nodeName); // 'DIV'
return () => {
console.log('cleaning up');
};
}
</script>
<div {@attach logMount}>...</div>

attachment run ตอน element mount; ถ้ามัน return function, function นั้นจะ run ตอน element unmount (หรือก่อนที่ attachment จะ re-run — ดูด้านล่าง) นั่นคือรูปร่างทั้งหมด: (element) => cleanup?

ส่วนที่น่าสนใจคือสิ่งที่เกิดขึ้นเมื่อ attachment อ่าน reactive state {@attach expr} run expr ข้างใน effect ดังนั้น attachment factory — ฟังก์ชันที่รับ argument แล้ว return attachment — จะถูก destroy แล้วสร้างใหม่อัตโนมัติทุกครั้งที่ค่าที่มันอ่านเปลี่ยน:

src/lib/AttachTooltip.svelte
<script lang="ts">
let { text, onRun }: { text: string; onRun: (n: number) => void } = $props();
let runs = 0;
// An attachment factory: (text) => (node) => cleanup?
function tooltip(t: string) {
return (node: HTMLElement) => {
runs += 1;
onRun(runs);
node.title = t;
};
}
</script>
<div {@attach tooltip(text)}>hover</div>

เทียบกับตัวเดียวกันที่เขียนเป็น legacy use: action โดยไม่มี method update:

src/lib/UseTooltip.svelte
<script lang="ts">
let { text, onRun }: { text: string; onRun: (n: number) => void } = $props();
let runs = 0;
// A use: action — runs ONCE per mount. Without an `update` method,
// a changed `text` argument is silently ignored.
function tooltip(node: HTMLElement, t: string) {
runs += 1;
onRun(runs);
node.title = t;
}
</script>
<div use:tooltip={text}>hover</div>

ทั้งสองดูเหมือนกันเกือบทุกอย่าง แต่ behave ต่างกันทันทีที่ text เปลี่ยนหลัง mount:

src/lib/attach-vs-use.test.ts
import { render } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import AttachTooltip from './AttachTooltip.svelte';
import UseTooltip from './UseTooltip.svelte';
test('an attachment factory re-runs when its argument changes; a use: action does not', async () => {
const attachRuns: number[] = [];
const { rerender } = render(AttachTooltip, {
props: { text: 'a', onRun: (n: number) => attachRuns.push(n) },
});
await rerender({ text: 'b', onRun: (n: number) => attachRuns.push(n) });
expect(attachRuns).toEqual([1, 2]); // ran again when `text` changed
const actionRuns: number[] = [];
const { rerender: rerenderAction } = render(UseTooltip, {
props: { text: 'a', onRun: (n: number) => actionRuns.push(n) },
});
await rerenderAction({ text: 'b', onRun: (n: number) => actionRuns.push(n) });
expect(actionRuns).toEqual([1]); // never ran again — text change was ignored
});
✓ an attachment factory re-runs when its argument changes; a use: action does not
Test Files 1 passed (1)
Tests 1 passed (1)

นั่นคือ trade-off หลัก: attachment factory เป็น reactive โดย default — มัน re-run ทุกครั้งที่สิ่งที่มันอ่านเปลี่ยน โดย destroy attachment เก่า (run cleanup ของมัน) ก่อน ส่วน use: action จะ re-run แค่ method update ของมันเท่านั้น และก็ต่อเมื่อคุณเขียนมันไว้; ถ้าไม่มี parameter ที่เปลี่ยนจะถูกเมินเฉย ๆ — bug เงียบ ๆ ที่พบบ่อยเมื่อโค้ดที่ AI generate สมมติว่า use:tooltip={text} “ทำงานได้เอง” เหมือน prop

ถ้า setup เองมี cost สูงและคุณต้องการให้แค่ ส่วนหนึ่ง ของ attachment reactive ให้อ่าน reactive value ข้างใน $effect ที่ nest เข้าไปแทนที่จะอ่านตอนต้นของ factory:

src/lib/OptimizedAttach.svelte
<script lang="ts">
let { text }: { text: string } = $props();
function tooltip(getText: () => string) {
return (node: HTMLElement) => {
veryExpensiveSetupWork(node);
$effect(() => {
node.title = getText(); // only this part re-runs when `text` changes
});
};
}
function veryExpensiveSetupWork(_node: HTMLElement) {}
</script>
<div {@attach tooltip(() => text)}>hover</div>

attachment บน component จะกลายเป็น prop ที่มี key เป็น Symbol; ถ้า component spread props ที่เหลือลงบน element, element นั้นจะได้รับ attachment ไปด้วย นี่ทำให้สร้าง wrapper component ที่เสริม element ที่มัน render ได้ โดย wrapper ไม่ต้องรู้จัก attachment เลยแม้แต่น้อย:

src/lib/Button.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
let { children, ...props }: { children?: Snippet } & Record<string, unknown> = $props();
</script>
<!-- `props` includes any attachment the caller passed -->
<button {...props}>{@render children?.()}</button>
src/lib/ButtonWithAttachment.svelte
<script lang="ts">
import Button from './Button.svelte';
function logAttach(): import('svelte/attachments').Attachment {
return (element) => {
element.setAttribute('data-attached', 'true');
};
}
</script>
<Button {@attach logAttach()}>Click</Button>

use: ใช้กับ component ไม่ได้เลย — ใช้ได้แค่กับ DOM element เท่านั้น ส่วน attachment ใช้ได้กับทั้ง element และ component ซึ่งเป็นหนึ่งในเหตุผลที่ docs อธิบาย attachment ว่า “ยืดหยุ่นและ composable กว่า”

สำหรับกรณีที่ต้องประกอบ attachment หลายตัวแบบ programmatic (เช่น จาก loop หรือรวมจากหลายแหล่ง) แทนที่จะเขียนมันตรง ๆ ใน markup createAttachmentKey จะสร้าง key แบบ symbol เดียวกัน ทำให้ spread ลงบน object ที่เป็น $state ได้:

src/lib/SpreadAttachment.svelte
<script lang="ts">
import { createAttachmentKey } from 'svelte/attachments';
let stuff = $state({
[createAttachmentKey()]: (node: HTMLElement) => console.log(node.nodeName),
});
</script>
<div {...stuff}>spread</div>

library หลายตัวยังคง ship action แบบ Svelte ปกติ (รูปร่าง (node, params?) => { update?, destroy? }) แทนที่จะเป็น attachment fromAction แปลง action เป็น attachment โดยพฤติกรรมเหมือนเดิมทุกประการ ทำให้ใช้กับ component หรือควบคู่กับ attachment อื่นได้:

src/lib/FromActionBridge.svelte
<script lang="ts">
import { fromAction } from 'svelte/attachments';
// A library-style action — untouched, still the classic (node, params) shape.
function legacyTooltip(node: HTMLElement, text: string) {
node.title = text;
return {
update(t: string) { node.title = t; },
};
}
let text = $state('hi');
</script>
<!-- Note the second argument is a FUNCTION that returns the action's
argument, not the argument itself — fromAction re-reads it reactively. -->
<div {@attach fromAction(legacyTooltip, () => text)}>hover</div>

ก่อนที่ attachment จะมีอยู่ วิธีเดียวที่ทำแบบนี้ได้คือ use: directive action คือฟังก์ชันที่รับ DOM node (และพารามิเตอร์ optional) ตอน element mount และอาจ return object ที่มี method update และ destroy:

src/lib/ClickOutsideDemo.svelte
<script lang="ts">
// A use: action: (node, params?) => { update?, destroy? }
function clickOutside(node: HTMLElement, callback: () => void) {
function handle(e: MouseEvent) {
if (!node.contains(e.target as Node)) callback();
}
document.addEventListener('click', handle, true);
return {
destroy() { document.removeEventListener('click', handle, true); },
};
}
let open = $state(false);
</script>
{#if open}
<div class="menu" use:clickOutside={() => (open = false)}>menu</div>
{/if}

update ถูกเรียกด้วย argument ใหม่ทุกครั้งที่มันเปลี่ยน (ทันทีหลังจาก Svelte apply การอัปเดต markup รอบข้าง); destroy ถูกเรียกตอน element unmount คุณจะยังเจอและเขียน use: เมื่อ library ส่ง action มาให้ตรง ๆ (library แบบ pre-attachment หลายตัวทำแบบนี้) หรือตอน maintain โค้ดเก่า — แต่สำหรับโค้ดใหม่ ให้ใช้ attachment แทน docs ของ Svelte พูดตรง ๆ เรื่องนี้:

In Svelte 5.29 and newer, consider using attachments instead, as they are more flexible and composable. — svelte.dev/docs/svelte/use

flowchart TB
  subgraph attach["{@attach factory(arg)} — Svelte 5.29+"]
    a1["arg changes"] --> a2["old attachment's cleanup runs"]
    a2 --> a3["factory(arg) called again"]
    a3 --> a4["new attachment runs"]
  end
  subgraph use["use:action={arg} — legacy"]
    u1["arg changes"] --> u2{"did the action\nreturn update()?"}
    u2 -->|"yes"| u3["update(newArg) runs"]
    u2 -->|"no — common bug"| u4["nothing happens, silently"]
  end
  unmount["element unmounts"] --> cleanup["attachment cleanup / action destroy()"]
Attachment เทียบกับ use: action

คุณ อาจ ทำบางส่วนนี้ด้วย bind:this บวก $effect attachment และ action ดีกว่าเมื่อ behavior นั้น reuse ได้ข้าม element และ self-contained (setup + teardown ในที่เดียว): cleanup ที่ return การันตี teardown และการส่ง argument ทำให้ declarative ที่จุดเรียก หยิบมาใช้เมื่อคุณอยากบอกว่า “element นี้ behave เหมือน X” และ reuse X ได้ทุกที่ — tooltip, click-outside, focus trap, จุด mount ของ chart library

attachment คือ “ฟังก์ชันที่ run ตอน element นี้มีอยู่ และ cleanup ตอนมันหายไป” — escape hatch เดียวกับที่ action เคยเป็นมาตลอด แค่ต่อสายผ่าน effect system ของ Svelte เองแทนที่จะเป็น action lifecycle เฉพาะทาง นี่คือเหตุผลที่ attachment factory re-run ได้เอง: การอ่าน reactive state ข้างใน effect ทำให้ effect นั้น re-run เหมือน $derived หรือ $effect ธรรมดา method update ของ action มีอยู่เพราะ action เกิดก่อน $effect ทั้งหมด (มันเก่ากว่า rune) — มันคือกลไก reactivity แบบขนาน ที่เขียนเองด้วยมือ ซึ่งจะทำงานก็ต่อเมื่อคุณจำได้ว่าต้องเขียนมัน attachment เลิกใช้ส่วนที่เขียนเองด้วยมือนั้น โดยขี่บน reactivity system ที่มีอยู่แล้ว

ทางเลือกข้อดีต้นทุน
{@attach factory(arg)}reactive โดย default (ไม่มี update ให้ลืม), ใช้ได้ทั้ง element และ component, composable ผ่าน fromAction/spreadต้องการ Svelte 5.29+; factory ใหญ่ที่อ่านหลายค่าจะ re-run ทั้งหมด เว้นแต่ดันการอ่านเข้า $effect ที่ nest
use:action={arg}คุ้นเคย ยังเป็นรูปร่างที่ library action ของบุคคลที่สามส่วนใหญ่ ship มาupdate เป็น opt-in และลืมง่าย; ใช้กับ component ไม่ได้เลย
fromAction(action, () => arg)reuse action ของ library เดิมโดยไม่แก้ไข แต่ทำให้มันทำงานบน component ได้มี layer indirection เพิ่มอีกชั้นเทียบกับเขียน attachment แบบ native
attachment factory ที่อ่าน state ตรง ๆ (ไม่มี $effect ที่ nest)โค้ดง่ายสุดre-run (และทำ setup ที่แพงซ้ำ) ทุกครั้งที่ dependency เปลี่ยน ไม่ใช่แค่ตัวที่สำคัญ
  • “use:action={value} อัปเดตอัตโนมัติเมื่อ value เปลี่ยน เหมือน prop” ไม่จริง — มีแค่ method update ของ action เท่านั้นที่ run ตอนพารามิเตอร์เปลี่ยน และก็ต่อเมื่อคุณเขียนมันไว้ พิสูจน์ด้านบน: onRun ของฝั่ง use: ไม่เคย run รอบสองเลย
  • “attachment เป็นแค่ชื่อใหม่ของ action” มันเป็นกลไกคนละอย่าง (effect ไม่ใช่ lifecycle เฉพาะทาง), ใช้กับ component ได้ (action ทำไม่ได้), และ reactivity ของมันอัตโนมัติแทนที่จะเขียนเองผ่าน update
  • “ต้อง rewrite use: action ทุกตัวให้เป็น attachment” ไม่ต้อง — fromAction เชื่อม action ที่มีอยู่แล้วให้เป็น attachment ที่ behavior เหมือนเดิมทุกประการ ไม่ต้องแตะ action ของ library ที่ทำงานอยู่แล้วเพื่อใช้กับ component
  • “argument ตัวที่สองของ attachment factory จะอ่าน live state เสมอตราบใดที่ส่ง expression” มีแค่การเรียกชั้นนอก (tooltip(text)) เท่านั้นที่ reactive เพราะมัน run ข้างใน effect; ค่าที่ถูก capture ครั้งเดียวข้างใน closure ที่ nest แล้วไม่เคยถูกอ่านซ้ำจะไม่อัปเดต — กติกาเดียวกับ reactivity อื่น ๆ ของ Svelte

เมื่อ component ที่ generate มาใช้ use: หรือ {@attach} ให้ตรวจสิ่งเหล่านี้ก่อนเชื่อมัน:

  • ที่ use:action={value} สมมติว่า value อัปเดต DOM อัตโนมัติ โดยไม่มี method update ที่ define ไว้บน action หรือเปล่า?
  • use: ถูกผูกกับ component (<MyComponent use:foo />) หรือเปล่า — แบบนั้นใช้ไม่ได้ attachment เท่านั้นที่ใช้กับ component ได้
  • attachment factory ทำ setup work ที่แพง ซึ่งควรแยกเข้า $effect ที่ nest เพื่อให้แค่ส่วนที่ถูกกว่า re-run หรือเปล่า?
  • action ที่พร้อมใช้ของ library ถูก rewrite เป็น attachment ด้วยมือ ทั้งที่ fromAction(action, () => arg) จะทำให้มันทำงานได้โดยไม่ต้องแก้หรือเปล่า?
  • action/attachment return cleanup จริงหรือเปล่า (destroy หรือ function ที่ return) — หรือมันรั่ว listener/subscription ตอน unmount?

🐛 หา bug ในโค้ดนี้

<script>
function tooltip(node, text) {
node.title = text;
}
let label = $state('Save your work');
</script>
<button use:tooltip={label}>Save</button>
<button onclick={() => (label = 'Saved!')}>Mark saved</button>
เฉลย

ดูสมเหตุสมผลดี — ส่ง label เข้า action tooltip แล้วคาดว่าข้อความ tooltip จะอัปเดตเป็น "Saved!" หลังกดปุ่ม แต่จะไม่เป็นแบบนั้น tooltip เป็น use: action ที่ไม่มี method update ดังนั้น Svelte เรียกมันแค่ครั้งเดียว ตอน mount ด้วยค่า เริ่มต้น ของ label การเปลี่ยนแปลงของ label ทุกครั้งหลังจากนั้นถูกเมินเฉย ๆ — พิสูจน์ด้านบนด้วย attach-vs-use.test.ts: run-counter ของฝั่ง use: ค้างที่ 1 ไม่ว่า argument ของมันจะเปลี่ยนกี่ครั้ง ในขณะที่ attachment factory ที่เทียบเท่ากัน counter ของมันเพิ่มขึ้นทุกครั้ง

วิธีแก้คือแบบใดแบบหนึ่งนี้:

<script>
function tooltip(node, text) {
node.title = text;
return { update(newText) { node.title = newText; } };
}
</script>

หรือ ที่ดีกว่าสำหรับโค้ดใหม่ สลับไปใช้ attachment factory ซึ่งได้พฤติกรรมนี้มาฟรี:

<script>
function tooltip(text) {
return (node) => { node.title = text; };
}
</script>
<button {@attach tooltip(label)}>Save</button>
อะไรคือความต่างของพฤติกรรมหลักระหว่าง {@attach tooltip(text)} กับ use:tooltip={text} เมื่อ text เปลี่ยนหลัง mount?
use:someAction ผูกกับ component ได้ไหม (<MyComponent use:someAction />)?
fromAction(action, () => arg) ทำอะไร?
หลัง label เปลี่ยน tooltip ของปุ่มจะแสดงอะไร?
<script>
  function tooltip(node, text) {
    node.title = text;
  }
  let label = $state('Save');
</script>
<button use:tooltip={label}>Save</button>
createAttachmentKey ในนี้มีไว้ทำอะไร?
<script>
  import { createAttachmentKey } from 'svelte/attachments';
  let stuff = $state({ [createAttachmentKey()]: (node) => console.log(node.nodeName) });
</script>
<div {...stuff}>spread</div>
ทำไมถึงอ่าน reactive value ข้างใน $effect ที่ nest แทนที่จะอ่านตรง ๆ ใน body ของ attachment factory?