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

Bindings

ปกติข้อมูลไหลทางเดียว: state → DOM binding เพิ่ม edge ย้อนกลับ เพื่อให้ form element เขียนกลับไปที่ state ด้วย bind:value เป็น shorthand ของ “set value ของ input จาก state นี้ และ update state นี้ตอน input”

<script>
let name = $state('');
</script>
<input bind:value={name} />
<p>Hello {name}</p>
<!-- Equivalent, written by hand:
<input value={name} oninput={(e) => name = e.currentTarget.value} /> -->

binding sync ทั้งสองอัตโนมัติ และใช้ได้กับ element ที่เป็น input-like ทุกตัว: bind:value สำหรับ text/number/select, bind:checked สำหรับ checkbox, bind:files สำหรับ file input

radio button และ checkbox group ที่แชร์ state ก้อนเดียวใช้ bind:group — Svelte wire ทั้งชุดเข้ากับ variable ตัวเดียว

<script>
let flavor = $state('vanilla'); // radios: a single value
let toppings = $state([]); // checkboxes: an array
</script>
<label><input type="radio" bind:group={flavor} value="vanilla" /> Vanilla</label>
<label><input type="radio" bind:group={flavor} value="chocolate" /> Chocolate</label>
<label><input type="checkbox" bind:group={toppings} value="nuts" /> Nuts</label>
<label><input type="checkbox" bind:group={toppings} value="cherry" /> Cherry</label>

สำหรับ radio, flavor เก็บค่าที่เลือก; สำหรับ checkbox, toppings เป็น array ของค่าที่ check

เพื่อได้ reference ของ DOM node จริง (หรือ component instance) ใช้ bind:this ตัวแปรจะถูก set หลัง element mount — อ่านค่าใน effect หรือ event handler ไม่ใช่ตอน initial render

<script>
let canvas = $state(); // will hold the DOM node
$effect(() => {
const ctx = canvas.getContext('2d'); // safe: runs after mount
ctx.fillRect(0, 0, 50, 50);
});
</script>
<canvas bind:this={canvas} width="200" height="200"></canvas>

parent จะ bind: กับ prop ของ child component ได้ก็ต่อเมื่อ child เปิดให้ prop นั้น ๆ ด้วย $bindable() (มีมาตั้งแต่ rune $props() เอง) ถ้าไม่มี bind: กับ prop นั้นจะเป็น error จริงที่ถูกจับได้ — ไม่ใช่ no-op เงียบ ๆ:

src/lib/BindableChild.svelte
<script lang="ts">
let { value }: { value: string } = $props();
</script>
<input value={value} />
src/lib/BindableParent.svelte
<!-- @expect-error bind: to a prop the child never declared $bindable() -->
<script lang="ts">
import BindableChild from './BindableChild.svelte';
let text = $state('hi');
</script>
<BindableChild bind:value={text} />

svelte-check จับกรณีนี้ที่ระดับ type พร้อมข้อความที่บอกวิธีแก้ตรง ๆ:

$ svelte-check --tsconfig ./tsconfig.json
src/lib/BindableParent.svelte:4:15
Cannot use 'bind:' with this property. It is declared as non-bindable inside
the component.
To mark a property as bindable: 'let { value = $bindable() } = $props()'

วิธีแก้ก็ตรงตามข้อความเป๊ะ — ประกาศ prop ด้วย $bindable() ที่ child แล้ว bind: เดิมบน parent จะ compile ผ่านสะอาด:

src/lib/BindableChildFixed.svelte
<script lang="ts">
let { value = $bindable() }: { value: string } = $props();
</script>
<input bind:value={value} />
src/lib/BindableParentFixed.svelte
<script lang="ts">
import BindableChildFixed from './BindableChildFixed.svelte';
let text = $state('hi');
</script>
<BindableChildFixed bind:value={text} />

data flow สองทางข้าม component boundary นั้นทรงพลังแต่ทำให้ data flow ดูคลุมเครือถ้าใช้เยอะเกิน — props-down/events-up มักชัดกว่าสำหรับอะไรที่เกินกว่า form control ง่าย ๆ นี่คือเหตุผลที่ $bindable() เป็น opt-in ต่อ prop ไม่ใช่ default

ตั้งแต่ Svelte 5.9 binding รับคู่ function {get, set} แทน identifier ธรรมดาได้ ทำให้ validate หรือ transform ค่าตอนมันไหลเข้ามาได้:

src/lib/FunctionBinding.svelte
<script lang="ts">
let value = $state('');
</script>
<input bind:value={() => value, (v) => (value = v.toUpperCase())} />
<p data-testid="value">{value}</p>

การพิมพ์ในช่องนี้ทุกตัวอักษรจะผ่าน function set ก่อนตกลงไปเป็น state — และเพราะ DOM value ถูกขับด้วย function get ที่อ่าน state ก้อนเดียวกัน input จะสะท้อนค่าที่ transform แล้วกลับมาทันที:

src/lib/function-binding.test.ts
import { render, fireEvent } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import FunctionBinding from './FunctionBinding.svelte';
test('a function binding transforms the value on the way in, and the transformed value reflects back into the input', async () => {
const { getByRole, getByTestId } = render(FunctionBinding);
const input = getByRole('textbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'hello' } });
expect(getByTestId('value').textContent).toBe('HELLO');
expect(input.value).toBe('HELLO');
});
✓ a function binding transforms the value on the way in, and the transformed value reflects back into the input
Test Files 1 passed (1)
Tests 1 passed (1)

นี่คือกลไกเดียวกับที่ $bindable() เองสร้างขึ้นมาข้างใน และเป็นวิธีที่แนวคิด “derive อย่าไป sync” จากโมดูล reactivity ขยายมาถึง binding: แทนที่จะเป็นกระจกสองทางธรรมดา คุณได้ประตูที่ควบคุมได้ ซึ่งค่าต้องผ่านทั้งสองทิศทาง

บน DOM element ธรรมดา bind:value (และเพื่อน ๆ) ถูกฝังไว้ใน Svelte เอง — input-like element ทุกตัวรองรับโดยไม่ต้อง setup อะไรเลย บน component bind: ใช้ได้แค่กับ prop ที่ component ประกาศเป็น $bindable() ตรง ๆ เท่านั้น ไม่มีพื้นผิว binding แบบ implicit เหมือนที่ <input> มี ความไม่สมมาตรนี้ตั้งใจให้เป็นแบบนั้น: ผู้เขียน component เป็นคนตัดสินว่า prop ไหนปลอดภัยให้ผู้เรียก mutate ได้ เหมือนที่ library ตัดสินว่า field ไหนใน public API ของมัน read-write หรือ read-only

flowchart TB
  el["DOM element (input, select, ...)"] --> builtin["bind:value/checked/group/this — built in, no opt-in needed"]
  comp["Component"] --> check{"prop declared\nwith $bindable()?"}
  check -->|"yes"| ok["bind: works — two-way flow across the boundary"]
  check -->|"no"| err["bind: fails svelte-check:\nCannot use 'bind:' with this property"]
bind: target มาจากไหน

binding คือ สัญญาที่มีทางเลือกให้ปิด ไม่ใช่ default บน element เบราว์เซอร์เปิดพื้นผิวที่ two-way ได้อยู่แล้ว (value, checked, DOM node เอง) Svelte แค่ wire มันเข้าด้วยกัน บน component ไม่มีพื้นผิวแบบนั้นจนกว่าผู้เขียนจะสร้างมันขึ้นมา — $bindable() คือการสร้างนั้น ทีละ prop มองทุก $bindable() เป็นการตัดสินใจ API เล็ก ๆ ว่า “ผู้เรียกสามารถเอื้อมเข้ามาที่ state ของฉันตรงนี้ได้” นี่คือเหตุผลที่ prop ธรรมดาที่ไม่ bindable ยังคงเป็นทางเดียว (props-down/events-up) เป็น default ที่สมเหตุสมผล ไม่ใช่การมองข้าม

ทางเลือกข้อดีต้นทุน
prop ทางเดียวธรรมดาdata flow ง่าย คาดเดาได้ — parent re-render เป็นทางเดียวที่ prop จะเปลี่ยนchild ต้อง emit event/callback เพื่อมีผลต่อ parent
prop แบบ $bindable()parent กับ child แชร์ state ก้อนเดียวโดยไม่ต้อง wire eventผู้เรียกใดก็ mutate state ของ parent ผ่าน child ได้ — สืบที่มาของการเปลี่ยนแปลงยากขึ้น
function binding bind:value={get, set}validation/transformation อยู่ที่จุด binding เอง ไม่กระจายไปตาม oninput handlerอ่านที่จุดเรียกยากกว่า bind:value={x} ธรรมดา
bind:thisเข้าถึง DOM node หรือ component instance ตรง ๆ พร้อม typeมีให้ใช้แค่หลัง mount — อ่านตอน render เป็น bug เหมือน escape hatch แบบ ref ทั่วไป
  • “prop ของ component ทุกตัว bind: ได้ เหมือน value ของ element” ไม่จริง — ใช้ได้แค่ prop ที่ component เปิดให้ด้วย $bindable() เท่านั้น พิสูจน์ด้านบน: svelte-check ปฏิเสธ bind: กับ prop ธรรมดาพร้อมข้อความบอกวิธีแก้ตรง ๆ
  • “set ของ function binding run แค่ครั้งเดียว ตอนค่าเริ่มต้น” มัน run ทุกครั้งที่เขียน — ทุกตัวอักษรที่พิมพ์ในตัวอย่าง input ด้านบน — เหมือนกับ setter โดยนัยของ bind:value ธรรมดา
  • “bind:this มีให้ใช้ทันที เพราะประกาศติดกับ element เลย” มันเป็น undefined/ว่างจนกว่าจะ mount เสร็จ; อ่านมันตอน initial render ของ component (ไม่ใช่ใน effect หรือ handler) จะไม่ได้อะไรเลย
  • “component binding กับ props-down/events-up ใช้แทนกันได้ เลือกอันที่พิมพ์สั้นกว่า” มันมี blast radius ต่างกัน: prop ที่ bindable ให้ child mutate state ของ parent ได้ตรง ๆ ซึ่งสืบที่มาได้ยากกว่า callback prop ที่ชัดเจนเมื่อมีอะไรเปลี่ยนโดยไม่คาดคิด

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

  • template ของ component ทำ <Child bind:value={x} /> โดยที่ child ไม่เคยประกาศ value = $bindable() ใน $props() ของตัวเองหรือเปล่า?
  • reference จาก bind:this ถูกอ่านใน render body (ไม่ใช่ใน $effect/event handler) ซึ่งจะเห็นว่ามันยังว่างอยู่หรือเปล่า?
  • function binding (bind:value={get, set}) แทนที่ bind:value ธรรมดาบวก $effect แยกที่คอย “แก้” ค่าทีหลังได้ไหม?
  • prop ของ component ถูก mark $bindable() ทั้งที่ component ไม่ได้ต้องการให้ parent สังเกตการเขียนจริง ๆ — เปิดพื้นผิว two-way ที่กว้างเกินความจำเป็นหรือเปล่า?
  • สำหรับ bind:group input radio/checkbox เหล่านั้นอยู่ใน component instance เดียวกัน จริงหรือเปล่า — bind:group ใช้ข้าม component boundary ไม่ได้

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

Child.svelte
<script>
let { open } = $props();
</script>
<button onclick={() => (open = !open)}>{open ? 'Close' : 'Open'}</button>
<!-- Parent.svelte -->
<script>
import Child from './Child.svelte';
let panelOpen = $state(false);
</script>
<Child bind:open={panelOpen} />
{#if panelOpen}<p>Panel content</p>{/if}
เฉลย

Child ไม่เคยประกาศ open เป็น $bindable() — มันเป็น prop ธรรมดาที่ destructure มา svelte-check จับกรณีนี้ที่ระดับ type ก่อนที่มันจะได้รันเลยด้วยซ้ำ:

Cannot use 'bind:' with this property. It is declared as non-bindable inside
the component.
To mark a property as bindable: 'let { open = $bindable() } = $props()'

วิธีแก้ตรงตามข้อความเป๊ะ:

<script>
let { open = $bindable() } = $props();
</script>

จนกว่าจะเพิ่มสิ่งนั้น นี่คือ error ที่ถูกจับตอน compile ไม่ใช่ bug เงียบ ๆ — แต่เหตุผลข้างหลังสำคัญสำหรับการอ่านโค้ดที่ AI เขียนซึ่งผสมทั้งสอง pattern ไม่สม่ำเสมอกันทั่วโค้ดเบส: bind: บน component ใช้ได้เฉพาะ prop ที่ผู้เขียน component เอง เปิดไว้ตั้งใจเท่านั้น ไม่ใช่สิ่งที่ผู้เรียกบังคับจากภายนอกได้

child component ต้องประกาศอะไรเพื่อให้ parent bind: กับ prop ของตัวเองได้?
จะเกิดอะไรขึ้นเมื่อ type-check โค้ดนี้?
<!-- Child.svelte -->
<script>
  let { count } = $props();
</script>

<!-- Parent.svelte -->
<script>
  import Child from './Child.svelte';
  let n = $state(0);
</script>
<Child bind:count={n} />
bind:value รับคู่ function {get, set} แทน variable ธรรมดาได้ตั้งแต่ Svelte เวอร์ชันไหน?
function set ใน bind:value={() => value, (v) => value = v.toUpperCase()} ทำอะไร?
reference จาก bind:this ปลอดภัยที่จะอ่านเมื่อไร?
ทำไม bind: บน DOM element ธรรมดา (เช่น <input bind:value>) ใช้ได้เลยโดยไม่ต้อง setup ในขณะที่ bind: บน component ต้องมี $bindable()?