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

The $derived Rune

$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>

$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
ค่า derived คำนวณใหม่จาก dependency ที่ track ไว้

$derived มักถูกอธิบายว่า “คำนวณใหม่อัตโนมัติ” ซึ่งฟังดูเหมือนมันรันทันทีที่ dependency เปลี่ยน แต่ไม่ใช่ reactivity ของ Svelte เป็นแบบ push-pull: dependency ที่เปลี่ยนผลัก (push) สัญญาณ “คุณ dirty แล้วนะ” ไปให้ derived แต่ฟังก์ชันของ derived จะรันจริงก็ต่อเมื่อมีคนดึง (pull) มันด้วยการอ่านในครั้งถัดไปเท่านั้น ถ้าไม่มีใครอ่านมันเลย มันจะไม่รันอีกเลย ไม่ว่า dependency จะเปลี่ยนกี่ครั้งก็ตาม

src/lib/derived-laziness.svelte.test.ts
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: คุณพึ่งพาไม่ได้ว่ามันจะรันกี่ครั้ง หรือรันตอนไหน — พึ่งพาได้แค่ว่าค่าของมันจะถูกต้องในครั้งถัดไปที่มีคนอ่าน

ก่อน 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 ตัวจริงที่เปลี่ยนจะล้างมันทิ้ง:

src/lib/derived-writable.svelte.test.ts
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)

เหมือน $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:

src/lib/derived-in-class.svelte.test.ts
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)

ความผิดพลาดที่พบบ่อยของคนที่มาจาก 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 จริง ๆ (บทถัดไป) ไม่ใช่สำหรับคำนวณค่า

มอง $derived เป็น สูตรที่ถูก cache ไม่ใช่ subscription การประกาศมันไม่ได้รันอะไร dependency ที่เปลี่ยนก็ไม่ได้รันอะไรเช่นกัน — มันแค่พลิก flag ภายในว่า “stale” สูตรจะรันแค่ครั้งเดียว แบบ lazy ตอนที่มีคนถามหาค่าครั้งถัดไป แล้วคำตอบจะถูก cache ไว้จนกว่า flag จะพลิกอีกครั้ง นี่คือเหตุผลที่ derived ที่ไม่เคยถูกอ่านฟรี, ทำไมมันต้องบริสุทธิ์ (พึ่งพาเมื่อไรหรือกี่ครั้งที่มันรันไม่ได้) และทำไม “การ override” มัน (writable derived) จริง ๆ แล้วก็แค่แทนที่คำตอบที่ cache ไว้จนกว่าการคำนวณใหม่ตัวจริงครั้งถัดไปจะล้างมันทิ้ง

ตัวเลือกได้อะไรเสียอะไร
$derived แทน $state + $effectSource 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 และถูกต้อง

เมื่อเจอ $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

$derived ทำอะไร?
ใช้ $derived.by แทน $derived เมื่อไร?
evaluations ตอนนี้เป็นเท่าไร?
let evaluations = 0;
let count = $state(0);
let double = $derived.by(() => { evaluations++; return count * 2; });
count = 1;
count = 2;
count = 3;
flushSync();
// (nothing has read `double` yet)
บน Svelte 5.25+ เกิดอะไรขึ้นตอน likes += 1 รัน?
<script>
  let serverLikes = $state(10);
  let likes = $derived(serverLikes);
  likes += 1; // reassign the derived value
</script>
ทำไมถึงควรเลือก $derived แทน $effect ที่เขียน derived state?
การคำนวณใน $derived.by เรียกฟังก์ชัน tracking analytics เป็น side effect โดยคาดว่ามัน fire หนึ่งครั้งต่อการเปลี่ยนของ dependency มีอะไรผิดพลาด?