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

Cross-Component State

Cross-component state ที่ง่ายที่สุดคือ reactive value ที่ export จาก module .svelte.js ทุกคนที่ import แชร์ instance เดียวกัน:

src/lib/theme.svelte.js
export const theme = $state({ mode: 'light' });
export function toggle() {
theme.mode = theme.mode === 'light' ? 'dark' : 'light';
}

component ไหนก็ตาม import theme และ toggle แล้ว sync กันเสมอ นี่เหมาะกับ state ที่ global จริง ๆ มี instance เดียว: สีธีม, toast queue, feature-flag set

มีข้อควรระวังที่สำคัญทันทีที่คุณใช้ SvelteKit (หรือ SSR ใด ๆ) $state ระดับ module คือ instance เดียวที่แชร์โดยทุกอย่างที่ import มัน — รวมถึงบน server ทุก user request ที่ process เดียวกันจัดการ เอา user ที่ login ใส่ใน module-level singleton แล้วภายใต้ SSR request หนึ่งอาจเห็นข้อมูลของอีก user ได้

กลไกเดียวกันเป๊ะนี้ — object เดียวทั้ง process ที่ถูก mutate โดย “request 1” มองเห็นได้แบบเงียบ ๆ จาก component ที่ไม่เคยแตะ request 1 เลย — พิสูจน์ตรง ๆ ไปแล้วด้วย effect จริงและ “request” จำลองสองอันในบท $state Rune มันคือกับดักเดียวกันไม่ว่า shared value จะถือ field เดียวหรือ ใช้ coordinate หลาย component แบบที่นี่: ความเสี่ยงมาจากการที่ export เป็น module-level ไม่ใช่จากจำนวน component ที่อ่านมัน

เมื่อคุณต้องการ state ที่แชร์กันภายใน subtreeแต่แยกกันต่อ instance — และปลอดภัยภายใต้ SSR — ให้รวม runed class/object กับ setContext / getContext parent สร้าง state แล้วใส่เข้า context; descendant อ่านมัน component instance แต่ละตัวได้ของตัวเอง ที่ถูกสร้างระหว่าง render ของ request นั้น

src/lib/CartProvider.svelte
<script>
import { setContext } from 'svelte';
import CartItemAdder from './CartItemAdder.svelte';
// created fresh per instance / per request — not a module singleton
const cart = $state({ items: [] });
setContext('cart', cart);
</script>
<CartItemAdder />
<span data-testid="parent-count">{cart.items.length}</span>
src/lib/CartItemAdder.svelte
<script>
import { getContext } from 'svelte';
const cart = getContext('cart'); // the same reactive object the parent set
</script>
<button data-testid="add" onclick={() => cart.items.push({ id: cart.items.length })}>
add ({cart.items.length})
</button>

เพราะ state ถูกสร้างข้างในต้นไม้ component (ไม่ใช่ตอน module load) แต่ละ render — และแต่ละ SSR request — ได้สำเนาของตัวเอง พิสูจน์ตรง ๆ: การ mount CartProvider สองครั้งติดกัน (แทน “request” สองอัน) ไม่เคยพา state ข้ามจาก mount หนึ่งไปอีก mount และสอง instance ที่ mount พร้อมกัน ก็ไม่เคยเห็นการเปลี่ยนแปลงของกันและกันเลย:

src/lib/context-isolation.test.ts
import { render, fireEvent, cleanup, within } from '@testing-library/svelte';
import { afterEach, expect, test } from 'vitest';
import CartProvider from './CartProvider.svelte';
afterEach(() => cleanup());
test('context-provided state is created fresh PER MOUNT (per "request"), unlike a module singleton', async () => {
// "Request 1"
const r1 = render(CartProvider);
await fireEvent.click(within(r1.container).getByTestId('add'));
expect(within(r1.container).getByTestId('parent-count').textContent).toBe('1');
r1.unmount();
// "Request 2" — a brand new mount. If cart were a module-level $state
// singleton (like the SSR trap proven in reactivity/state-rune), this would
// start at 1 (leaked from request 1). With context, it starts fresh.
const r2 = render(CartProvider);
expect(within(r2.container).getByTestId('parent-count').textContent).toBe('0');
r2.unmount();
});
test('two concurrently mounted instances never see each other\'s context state', async () => {
const a = render(CartProvider);
const b = render(CartProvider);
await fireEvent.click(within(a.container).getByTestId('add'));
await fireEvent.click(within(a.container).getByTestId('add'));
expect(within(a.container).getByTestId('parent-count').textContent).toBe('2');
expect(within(b.container).getByTestId('parent-count').textContent).toBe('0'); // untouched
a.unmount();
b.unmount();
});
✓ context-provided state is created fresh PER MOUNT (per "request"), unlike a module singleton
✓ two concurrently mounted instances never see each other's context state
Test Files 1 passed (1)
Tests 2 passed (2)

การ mount component แทน “request” แน่นอนว่าเป็นการทำให้ง่ายขึ้น — มันไม่ได้ทดสอบ server request pipeline จริงของ SvelteKit แต่มันพิสูจน์กลไกจริงที่ context พึ่งพาสำหรับความปลอดภัยของ SSR: state ที่ประกาศข้างใน <script> ของ component ถูก instantiate ใหม่ทุกครั้งที่ component นั้นถูก render โดยไม่มี object ที่แชร์เชื่อม render pass ที่แยกกันเลย นั่นแหละคือสิ่งที่ทำให้มันปลอดภัยข้าม concurrent SSR request ซึ่งแต่ละอัน render component tree แยกจากกัน แพทเทิร์นทั่วไปคือ factory เล็ก ๆ (createCart() ที่พิสูจน์แล้วว่าอิสระต่อการเรียกในบท Runes vs Stores) ที่ return runed state เรียกใน parent แล้วแชร์ผ่าน context

แพทเทิร์นข้างบนคือสิ่งที่แอป SvelteKit จริงใช้ +layout.svelte ทำพอดี: <script> ของ layout รันใหม่ทุกครั้งที่ layout นั้นถูก render ดังนั้น state ที่สร้างและใส่เข้า context ที่นั่นเป็น per-request โดยธรรมชาติ ไม่มีทางเป็น singleton ข้าม request เลย

src/routes/+layout.svelte
<script>
import { setContext } from 'svelte';
let { children } = $props();
// Created fresh on EVERY render of this layout — never a module singleton,
// so it can never leak between concurrent requests on the server.
const cart = $state({ items: [] });
setContext('cart', cart);
</script>
{@render children()}
src/routes/+page.svelte
<script>
import { getContext } from 'svelte';
const cart = getContext('cart');
</script>
<button onclick={() => cart.items.push({ id: cart.items.length })}>
add to cart ({cart.items.length})
</button>

ทุก route ที่ซ้อนอยู่ใต้ layout นี้แชร์ instance cart เดียวกันผ่าน context — พฤติกรรมการแชร์ใน subtree แบบเดียวกับที่โชว์ข้างบนด้วย CartProvider/CartItemAdder — แต่ scope อยู่แค่ render ของ request เดียวของ layout ไม่ใช่ของ module เทียบตรง ๆ กับกับดักสองส่วนก่อนหน้า: export const cart = $state({ items: [] }) ใน module .svelte.js ที่ +layout.svelte import จะสร้าง cart แค่ตัวเดียวสำหรับทั้ง server process; setContext ข้างใน <script> ของ layout เองสร้างตัวใหม่ต่อ render แทน

  1. Local — $state ใน component เริ่มที่นี่
  2. Lift — ย้าย $state ไปที่ parent ร่วมที่ใกล้ที่สุด ส่งลงมาเป็น props สำหรับ component ที่เกี่ยวข้องกันไม่กี่ตัว
  3. Context — setContext/getContext กับ runed state สำหรับ subtree ที่ต้องการ instance ของตัวเอง (และ SSR safety) — พิสูจน์ข้างบนแล้วว่าถูกสร้างใหม่ทุก render ไม่เคยแชร์ข้าม mount
  4. Module — export $state จาก module .svelte.js สำหรับ state ที่ global จริง ๆ instance เดียว
  5. Store — เมื่อคุณต้องการ subscribe contract (interop, external push source)

เลือกระดับต่ำที่สุดที่แก้ปัญหาได้ ค่อยขยับขึ้นเมื่อจำเป็นเท่านั้น

flowchart TB
  local["Just one component? local $state"] --> up["A few related components? lift $state to the parent, pass props"]
  up --> ctx["A subtree, per-instance / per-user? setContext + runed state"]
  ctx --> mod["Truly global, single instance? export $state from a .svelte.js module"]
  mod --> store["Need the subscribe contract? a store"]
แพทเทิร์นแชร์ state แต่ละแบบเหมาะกับอะไร

ทุกแพทเทิร์นข้างบนตอบคำถามเดียวกัน — “reactive object นี้ถูกสร้างที่ไหน” — ด้วย scope ที่ต่างกัน Module-level export ถูกสร้างครั้งเดียวตอน import time สำหรับอายุของ process ทั้งหมด — นั่นแหละคือเหตุผลพอดีที่มันถูกแชร์โดยทุกอย่างตลอดไป รวมถึงข้าม server request ที่ไม่เกี่ยวข้องกันเลย State ที่สร้างข้างใน <script> ของ component (รวมถึง context) ถูกสร้างใหม่ทุกครั้งที่ component instance นั้นถูกตั้งค่า — ดังนั้น context จึง”ปลอดภัยกว่า”ไม่ใช่เพราะ SvelteKit magic พิเศษอะไร แต่เพราะมันสร้างบนการ instantiate component ธรรมดา ที่เกิดขึ้นครั้งเดียวต่อ render/request อยู่แล้วโดยโครงสร้าง การ lift ไป parent ก็คือไอเดียเดียวกันที่ scope เล็กที่สุดเท่าที่จะเป็นได้: parent component instance คือ “container” ที่ state ถูกสร้างข้างใน

ตัวเลือกได้อะไรเสียอะไร
Local $stateง่ายที่สุด; ไม่ต้องต่ออะไรเลยมองเห็นได้แค่ component เดียว
Lift ไป parent + propsยังง่าย; data flow ชัดเจนProp-drilling โตตามความลึกของ tree
setContext/getContextInstance ใหม่ต่อ subtree พิสูจน์แล้วว่าปลอดภัยข้าม mount/request แยกกันพิธีการมากขึ้นนิดหน่อย (provider + consumer); implicit (ไม่เห็นใน props)
Module .svelte.js singletonไม่มีพิธีการเลย เข้าถึงได้จากทุกที่Instance เดียวสำหรับทั้ง process — พิสูจน์แล้วว่าไม่ปลอดภัยสำหรับข้อมูลต่อ user ภายใต้ SSR
Store (writable/readable)Subscribe contract สำหรับ interopพิธีการมากกว่า rune สำหรับ app state ธรรมดา
  • “Context กับ module state ก็แค่ $state เหมือนกัน เลยพฤติกรรมเหมือนกันภายใต้ SSR” พิสูจน์แล้วว่าผิด (ผ่าน test SSR ของบท $state Rune บวกกับ proof การแยก mount ข้างบน): module state คือ instance เดียวสำหรับทั้ง process; state ที่มาจาก context ถูกสร้างใหม่ทุก render ดังนั้นสอง render ที่เกิดพร้อมกันไม่เคยแชร์ instance เลย
  • “setContext ใน +layout.svelte ต้องการ API เฉพาะของ SvelteKit สำหรับ per-request ถึงจะปลอดภัย” ไม่จริง — มันปลอดภัยด้วยเหตุผลเดียวกันเป๊ะกับที่ component-local state ใด ๆ ปลอดภัย: <script> ของ layout รันใหม่ทุกครั้งที่ layout render ซึ่งคือครั้งเดียวต่อ request ที่เกี่ยวข้อง
  • “การ lift state ไป parent กับการใช้ context มีพิธีการพอ ๆ กัน เลือกอันไหนก็ได้” การ lift หมายถึงส่ง props ลงมาแบบชัดเจน (มองเห็นได้ แต่โตตามความลึก) context เป็น implicit (ไม่มี props) แต่ต้องการทั้ง provider และ consumer ที่ตกลง key เดียวกัน — trade-off ต่างกัน ไม่ใช่พิธีการเท่ากัน
  • “factory function ที่ใช้ใน context (createCart()) แชร์ state กับ factory ที่ใช้ที่ module scope” พิสูจน์แล้วว่าผิดในบท Runes vs Stores: factory return instance อิสระทุกครั้งที่เรียกเสมอ ไม่ว่าการเรียกนั้นจะเกิดที่ไหน — สิ่งที่กำหนดว่าจะแชร์หรือแยกกันคือจุดที่เรียกกับ shape ของ export ไม่ใช่ตัว factory เอง

เมื่อเจอ shared state ที่ต่อข้าม component ให้เช็คสิ่งเหล่านี้:

  • State ที่ควรจะเป็นต่อ user หรือต่อ request (user ที่ login, session, cart ที่ผูกกับ login) ถูก export ตรง ๆ จาก module .svelte.js แทนที่จะถูกสร้างข้างใน component/layout แล้วแชร์ผ่าน context ไหม? พิสูจน์ข้างบนแล้ว: เวอร์ชัน module คือ instance เดียวสำหรับทั้ง server process
  • ในแอป SvelteKit setContext ถูกเรียกข้างใน <script> ของ +layout.svelte เอง (ปลอดภัย สดใหม่ทุก render) — หรือ state ที่มัน set จริง ๆ มาจาก module-level singleton ที่ import เข้ามา (ไม่ปลอดภัย ทำลายจุดประสงค์ของการใช้ context ไปเลย)?
  • Component ที่ซ้อนลึกได้รับค่าสามสี่ตัวเดิมผ่าน props ผ่านห้าชั้นของ component ที่ไม่ได้ใช้มันเองเลยไหม — สัญญาณว่า setContext/getContext (หรือ lift น้อยกว่านี้) จะลดพิธีการที่ไม่จำเป็นออกได้จริง?
  • getContext ถูกเรียกด้วย key ที่ไม่เคยถูก set จริงโดย ancestor บนเส้นทางที่จะ render ไหม — return undefined ตอน runtime โดยไม่มีการเช็คตอน compile-time เลย?

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

src/lib/session.svelte.ts
export const session = $state({ user: null });
// src/routes/+layout.svelte
<script>
import { setContext } from 'svelte';
import { session } from '../lib/session.svelte.ts';
// "Make the session available to the whole app via context"
setContext('session', session);
let { children } = $props();
</script>
{@render children()}
เฉลย

โค้ดนี้ดูเหมือนกำลังใช้ context เพื่อความปลอดภัยต่อ request แต่ไม่ใช่เลย: session ถูก import จาก module .svelte.js/.svelte.ts ซึ่งมันถูกสร้างไปแล้วเป็น instance เดียวทั้ง process — พิสูจน์แล้วในบท $state Rune ว่าถูกแชร์โดยทุก importer สำหรับทั้ง server process การห่อ object เดียวกันนั้นด้วย setContext ไม่ได้สร้าง instance ใหม่ต่อ request เลย มันแค่ส่ง reference ไปยัง singleton ระดับ module ตัวเดิมให้ descendant ห่างออกไปอีกชั้นหนึ่งเท่านั้น ทุก request ที่เกิดพร้อมกันบน server process นี้ได้ session object เดียวกันเป๊ะ ดังนั้น session.user ของ user คนหนึ่งจะเขียนทับค่าที่ทุก concurrent request อื่นเห็น — นี่คือ SSR leak แบบเดียวกับที่ context pattern มีไว้ป้องกันพอดี แค่ยังอยู่ตรงนั้น เพียงแต่สังเกตยากขึ้นเพราะมี setContext/getContext มาเกี่ยวข้อง ทางแก้คือสร้าง state ข้างใน <script> ของ layout เอง — const session = $state({ user: null }) เขียนตรง ๆ ใน +layout.svelte ไม่ใช่ import จาก module — เพื่อให้มี instance ใหม่ต่อ render เหมือนตัวอย่าง CartProvider/+layout.svelte ที่พิสูจน์แล้วว่าปลอดภัยข้างบน

กับดัก SSR singleton คืออะไร?
แชร์ state ภายใน subtree แบบปลอดภัยต่อ instance/ต่อ request ทำยังไง?
Module .svelte.js singleton เหมาะกับ state แบบไหน?
cart นี้ปลอดภัยจากการรั่วระหว่าง request ของ user สองคนที่เกิดพร้อมกันไหม?
<!-- src/routes/+layout.svelte -->
<script>
  import { setContext } from 'svelte';
  const cart = $state({ items: [] }); // declared HERE, inside the layout
  setContext('cart', cart);
  let { children } = $props();
</script>
{@render children()}
หลักการนำทางในการเลือกแพทเทิร์นแชร์ state คืออะไร?
Module .svelte.js export export const session = $state({ user: null }) แล้ว +layout.svelte เรียก setContext('session', session) การห่อมันด้วย setContext ทำให้ปลอดภัยต่อ request ไหม?