The $derived Rune
ค่าคำนวณ (Computed values)
หัวข้อที่มีชื่อว่า “ค่าคำนวณ (Computed values)”$derived ประกาศค่าที่คำนวณจากค่า reactive อื่น ๆ มันคำนวณใหม่อัตโนมัติทุกครั้งที่ค่าที่มันอ่านเปลี่ยน — และมันถูก cache ไว้ จึงคำนวณใหม่เฉพาะตอน dependency เปลี่ยนจริง ๆ
<script> let count = $state(2); let double = $derived(count * 2); // 4, and stays in sync let label = $derived(`count is ${count}`);</script>
<p>{double} — {label}</p>คุณไม่ต้องระบุ dependency เอง compiler เห็นว่า double อ่าน count และสร้าง double ใหม่พอดีตอน count เปลี่ยน ค่า derived เป็น read-only โดย default — ปกติคุณไม่ assign ให้มันตรง ๆ มันคือฟังก์ชันของ input ของมัน (มีข้อยกเว้นตั้งใจอยู่หนึ่งอย่าง — ดู “Writable deriveds” ด้านล่าง)
คอมไพล์ตัวอย่างนี้ด้านล่างแล้วสังเกต $.derived(() => count * 2) ในผลลัพธ์ — นั่นคือ rune ทั้งหมด: cell ที่คำนวณใหม่แบบ lazy ห่อฟังก์ชันที่คุณเขียน ไม่ใช่การรันคอมโพเนนต์ทั้งตัวใหม่
<script> // English comments: keep this pinned to Svelte 5 runes mode. let count = $state(2); let double = $derived(count * 2);</script>
<button onclick={() => count++}>{double}</button> Logic ที่ซับซ้อน: $derived.by
หัวข้อที่มีชื่อว่า “Logic ที่ซับซ้อน: $derived.by”$derived(expr) รับ expression เดียว เมื่อการคำนวณต้องการหลาย statement — loop, ตัวแปรชั่วคราว, branch — ให้ใช้ $derived.by พร้อมฟังก์ชันที่ return ค่า
<script> let numbers = $state([1, 2, 3, 4]);
let stats = $derived.by(() => { let sum = 0; for (const n of numbers) sum += n; return { sum, avg: sum / numbers.length }; });</script>
<p>sum {stats.sum}, avg {stats.avg}</p>$derived(x) เป็นแค่ shorthand ของ $derived.by(() => x) dependency ยัง track อัตโนมัติเหมือนเดิม — อะไรก็ตามที่ฟังก์ชันอ่านจะกลายเป็น dependency
flowchart LR a["$state count"] --> d["$derived double = count * 2"] b["$state numbers"] --> e["$derived.by stats"] d --> ui["DOM updates when inputs change"] e --> ui
Push-pull: พิสูจน์ lazy evaluation
หัวข้อที่มีชื่อว่า “Push-pull: พิสูจน์ lazy evaluation”$derived มักถูกอธิบายว่า “คำนวณใหม่อัตโนมัติ” ซึ่งฟังดูเหมือนมันรันทันทีที่ dependency เปลี่ยน แต่ไม่ใช่ reactivity ของ Svelte เป็นแบบ push-pull: dependency ที่เปลี่ยนผลัก (push) สัญญาณ “คุณ dirty แล้วนะ” ไปให้ derived แต่ฟังก์ชันของ derived จะรันจริงก็ต่อเมื่อมีคนดึง (pull) มันด้วยการอ่านในครั้งถัดไปเท่านั้น ถ้าไม่มีใครอ่านมันเลย มันจะไม่รันอีกเลย ไม่ว่า dependency จะเปลี่ยนกี่ครั้งก็ตาม
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
test('$derived is push-pull: dependency changes mark it dirty, but it only re-evaluates on next READ', () => { let evaluations = 0; const destroy = $effect.root(() => { let count = $state(0); let double = $derived.by(() => { evaluations++; return count * 2; });
flushSync(); expect(evaluations).toBe(0); // never read yet — nothing has evaluated it
void double; // read #1 expect(evaluations).toBe(1); void double; // read #2, no dependency changed — cached, no re-evaluation expect(evaluations).toBe(1);
// Change the dependency THREE times without reading `double` in between. count = 1; count = 2; count = 3; flushSync(); expect(evaluations).toBe(1); // still 1 — "push" only marked it dirty, didn't recompute
void double; // one read now triggers exactly one re-evaluation, for the LATEST value expect(evaluations).toBe(2); expect(double).toBe(6); // 3 * 2, not 2 or 4 — it skipped the intermediate values entirely }); destroy();});✓ $derived is push-pull: dependency changes mark it dirty, but it only re-evaluates on next READ
Test Files 1 passed (1) Tests 1 passed (1)ผลที่ตามมาในทางปฏิบัติ: $derived ที่ไม่เคยถูกอ่าน (dead code หรือค่าที่ใช้แค่หลัง condition ที่ไม่เคยเป็นจริง) ไม่มีต้นทุนอะไรเลยตอน runtime นอกจากตอนประกาศ — พิสูจน์แล้วด้วยการเปลี่ยน dependency ห้าครั้งแล้วสังเกตว่า evaluation เป็นศูนย์:
✓ an unread $derived never evaluates at all, even across many dependency changes
Test Files 1 passed (1) Tests 1 passed (1)นี่ก็เป็นเหตุผลที่ $derived ต้องปราศจาก side effect: คุณพึ่งพาไม่ได้ว่ามันจะรันกี่ครั้ง หรือรันตอนไหน — พึ่งพาได้แค่ว่าค่าของมันจะถูกต้องในครั้งถัดไปที่มีคนอ่าน
Writable derived (ตั้งแต่ Svelte 5.25)
หัวข้อที่มีชื่อว่า “Writable derived (ตั้งแต่ Svelte 5.25)”ก่อน Svelte 5.25 deriveds เป็น read-only เข้มงวด ตั้งแต่ 5.25 คุณ reassign ค่า $derived ได้ — ค่าที่ override จะคงอยู่จนกว่า dependency ตัวจริงจะเปลี่ยนแล้วบังคับให้คำนวณใหม่ ตอนนั้นมันจะกลับไปเป็น derived ตามปกติ use case หลักคือ optimistic UI: โชว์ค่าใหม่ทันที แล้วค่อยกระทบยอดกับ server
<script> let { post, like } = $props();
let likes = $derived(post.likes);
async function onclick() { likes += 1; // optimistic: show it immediately try { await like(); // tell the server } catch { likes -= 1; // failed — roll back } }</script>
<button {onclick}>🧡 {likes}</button>พิสูจน์แล้ว — override เห็นผลทันที และ dependency ตัวจริงที่เปลี่ยนจะล้างมันทิ้ง:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
test('a $derived value can be reassigned (writable derived, since Svelte 5.25) for optimistic UI', () => { const destroy = $effect.root(() => { let serverLikes = $state(10); let likes = $derived(serverLikes);
expect(likes).toBe(10);
// Optimistic bump: reassign the derived value directly, ahead of the server. likes += 1; expect(likes).toBe(11); // the override is visible immediately
// The override holds ONLY until a real dependency change forces a recompute. serverLikes = 20; // server eventually reports the real count flushSync(); expect(likes).toBe(20); // the manual override is gone — back to deriving from source }); destroy();});✓ a $derived value can be reassigned (writable derived, since Svelte 5.25) for optimistic UI
Test Files 1 passed (1) Tests 1 passed (1)const $derived reassign ไม่ได้ — เป็น compile error จริง ไม่ใช่แค่ lint warning:
test('a $derived declared with `const` cannot be reassigned (compile error)', async () => { const { compile } = await import('svelte/compiler'); const source = `<script> let count = $state(0); const double = $derived(count * 2); function bump() { double += 1; }</script><button onclick={bump}>{double}</button>`; expect(() => compile(source, { generate: 'client' })).toThrowError();});✓ a $derived declared with `const` cannot be reassigned (compile error)
Test Files 1 passed (1) Tests 1 passed (1)Derived ใน class
หัวข้อที่มีชื่อว่า “Derived ใน class”เหมือน $state, $derived ใช้เป็น class field ได้ คำนวณจาก field อื่นบน instance เดียวกัน:
<script> class Cart { items = $state([]); total = $derived(this.items.reduce((sum, i) => sum + i.price * i.qty, 0));
add(price, qty) { this.items.push({ price, qty }); } }
const cart = new Cart();</script>
<p>Total: {cart.total}</p>พิสูจน์แล้ว คำนวณใหม่ถูกต้องตอนเพิ่ม item:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
class Cart { items = $state<{ price: number; qty: number }[]>([]); total = $derived(this.items.reduce((sum, i) => sum + i.price * i.qty, 0));
add(price: number, qty: number) { this.items.push({ price, qty }); }}
test('$derived works as a class field, recomputing from other reactive fields on the same instance', () => { const log: number[] = []; const destroy = $effect.root(() => { const cart = new Cart(); $effect(() => { log.push(cart.total); }); flushSync(); cart.add(10, 2); flushSync(); cart.add(5, 1); flushSync(); }); destroy(); expect(log).toEqual([0, 20, 25]);});✓ $derived works as a class field, recomputing from other reactive fields on the same instance
Test Files 1 passed (1) Tests 1 passed (1)Derive อย่าไป sync ด้วย effect
หัวข้อที่มีชื่อว่า “Derive อย่าไป sync ด้วย effect”ความผิดพลาดที่พบบ่อยของคนที่มาจาก React คือคำนวณค่าใน effect แล้วเขียนลง state:
<script> let count = $state(2); let double = $state(0);
// ❌ Don't do this — an effect that mirrors derived state. $effect(() => { double = count * 2; });</script>แบบนี้แย่กว่าทุกทาง: มี state variable เกิน, มี update cycle เกิน, มีโอกาสที่ double จะ out of sync ชั่วขณะ และ data flow ตามยากขึ้น บท Effect Rune พิสูจน์ส่วน “out of sync ชั่วขณะ” ด้วย test จริงที่รันได้ — เวอร์ชันที่ mirror ผ่าน effect ล้าหลังจริงหนึ่งจังหวะ เครื่องมือที่ถูกต้องคือ $derived:
<script> let count = $state(2); let double = $derived(count * 2); // ✅ one source of truth, always in sync</script>กฎง่าย ๆ: ถ้าค่าคำนวณจาก state อื่นได้ ให้ derive มัน — อย่าเก็บแล้ว sync ใช้ $effect เฉพาะ side effect จริง ๆ (บทถัดไป) ไม่ใช่สำหรับคำนวณค่า
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”มอง $derived เป็น สูตรที่ถูก cache ไม่ใช่ subscription การประกาศมันไม่ได้รันอะไร dependency ที่เปลี่ยนก็ไม่ได้รันอะไรเช่นกัน — มันแค่พลิก flag ภายในว่า “stale” สูตรจะรันแค่ครั้งเดียว แบบ lazy ตอนที่มีคนถามหาค่าครั้งถัดไป แล้วคำตอบจะถูก cache ไว้จนกว่า flag จะพลิกอีกครั้ง นี่คือเหตุผลที่ derived ที่ไม่เคยถูกอ่านฟรี, ทำไมมันต้องบริสุทธิ์ (พึ่งพาเมื่อไรหรือกี่ครั้งที่มันรันไม่ได้) และทำไม “การ override” มัน (writable derived) จริง ๆ แล้วก็แค่แทนที่คำตอบที่ cache ไว้จนกว่าการคำนวณใหม่ตัวจริงครั้งถัดไปจะล้างมันทิ้ง
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
$derived แทน $state + $effect | Source of truth เดียว สม่ำเสมอเสมอ ไม่มี render cycle เกิน | ไม่มีข้อเสียเมื่อค่าคือฟังก์ชันบริสุทธิ์ของ reactive state อื่น |
$derived.by แทน $derived | รองรับ logic หลาย statement (loop, branch) | พิธีการมากกว่า expression เดียวนิดหน่อย |
| Writable derived (optimistic override) | Feedback ทันทีก่อน round-trip ไป server | คงอยู่แค่จนกว่า dependency ตัวจริงจะเปลี่ยน — อย่าพึ่งมันเป็น storage ถาวร |
$derived ที่มี side effect | — | ไม่มีข้อดี — side effect ใน $derived ไม่น่าเชื่อถือโดยธรรมชาติ: laziness แปลว่าคุณทำนายไม่ได้ว่ามันจะ fire กี่ครั้งหรือเมื่อไร |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
$derivedคำนวณใหม่ทันทีที่ dependency เปลี่ยน” ไม่จริง — พิสูจน์แล้วข้างบน dependency ที่เปลี่ยนแค่ mark ว่า dirty การคำนวณจริงเกิดแบบ lazy ตอนอ่านครั้งถัดไป - “
$derivedที่ไม่เคยถูกอ่านยังมีต้นทุนเพราะ dependency ของมันเปลี่ยน” พิสูจน์แล้วว่าไม่จริง: evaluation เป็นศูนย์ตลอดการเปลี่ยน dependency ห้าครั้งเมื่อไม่มีใครอ่านค่าเลย - “ใส่
console.logหรือเรียก API ใน$derivedก็โอเค เหมือน effect นั่นแหละ” ไม่เหมือนกัน — derived รันได้ตั้งแต่ศูนย์ครั้งไปจนถึงคำนวณใหม่ตอนที่คุณควบคุมไม่ได้ ขึ้นอยู่กับจังหวะการอ่านล้วน ๆ side effect ต้องอยู่ใน$effect - คุณอาจเห็น
state_referenced_locallyจาก compiler (“This reference only captures the initial value…”) ตอนอ่านตัวแปร$state/$derivedตรง ๆ ในโค้ดสไตล์ test หลังจาก reassign มันใน scope เดียวกัน มันเป็น warning ที่มีประโยชน์จริงสำหรับ closure ที่เก็บไว้ใช้ทีหลัง (อย่างsetContext) แต่การอ่าน-แล้ว-assert แบบ synchronous ใน tick เดียวกัน — แบบใน test ในหน้านี้ — ยังคงเห็นค่าที่ live และถูกต้อง
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อเจอ $derived ให้เช็คสิ่งเหล่านี้ก่อนเชื่อมัน:
- expression ของ derived mutate state หรือเรียกอะไรที่มี side effect หรือเปล่า (เรียก API,
console.logที่ใช้เกินกว่า debug, push เข้า array ที่ประกาศนอกมัน)? Svelte ห้าม mutate state ใน$derivedแต่ side effect ที่ไม่ใช่ state จะ compile ผ่านแล้วพังทีหลัง - ค่าถูกคำนวณใน
$effectแล้วเขียนลง$stateแยกต่างหาก ทั้งที่ทำเป็น$derivedตรง ๆ จาก input ของมันได้เลย? - ค่า
$derivedถูก reassign ที่ไหนสักที่โดยคาดว่า override จะอยู่ถาวร แทนที่จะเข้าใจว่ามันอยู่แค่จนกว่าจะมีการคำนวณใหม่ตัวจริงครั้งถัดไป? $derived.byถูกใช้แค่ห่อ expression เดียว (ไม่ได้ประโยชน์อะไรเหนือ$derivedธรรมดา) หรือ$derivedธรรมดาถูกใช้ผิดกับ logic ที่ต้องการหลาย statement จริง ๆ?- class ผสม field
$derivedแบบ reactive กับ getter คำนวณธรรมดาที่ไม่ reactive แบบไม่สม่ำเสมอ?
🐛 หา bug ในโค้ดนี้
function useAnalytics(events) {let count = $state(0);
// "Track every time the filtered list changes" — logs a side effect// inside the derived computation itself.let filtered = $derived.by(() => { const result = events.filter((e) => e.type === 'click'); trackAnalytics('filtered-recomputed', result.length); // side effect! return result;});
return { get filtered() { return filtered; } };}เฉลย
trackAnalytics(...) รันอยู่ภายใน callback ของ $derived.by ซึ่งผู้เขียนสมมติว่ามัน fire พอดีหนึ่งครั้งต่อการเปลี่ยนของ events — แต่ $derived เป็น push-pull และ lazy: มันคำนวณใหม่ก็ต่อเมื่อมีคนอ่าน filtered จริง ๆ ในครั้งถัดไป และไม่คำนวณเลยถ้าไม่มีใครอ่าน พิสูจน์ในโปรบแล้ว: การเปลี่ยน dependency ห้าครั้งโดยไม่มีการอ่านคั่นกลางทำให้ derived function evaluate ศูนย์ครั้ง และการอ่านครั้งเดียวถัดมาทำให้ evaluate แค่หนึ่งครั้งซึ่งสะท้อนแค่ state ล่าสุด — การเปลี่ยนแปลงระหว่างกลางถูกข้ามไปเงียบ ๆ ดังนั้น trackAnalytics ตรงนี้ fire จำนวนครั้งที่คาดเดาไม่ได้ — อาจไม่ fire เลย หรือ fire แค่ครั้งเดียวสำหรับการเปลี่ยนหลายครั้งที่ถูกรวมกัน — ใช้ไม่ได้กับอะไรก็ตามที่ต้องนับ state transition จริง ทางแก้คือย้ายการเรียก tracking ไปไว้ใน $effect ที่อ่าน filtered เพราะ $effect คือ rune ที่รับประกัน “รันเมื่อสิ่งนี้เปลี่ยน” ไม่ใช่ $derived