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

Loading Data

เพจไม่ได้ fetch ข้อมูลของตัวเองใน component แต่ load function ที่อยู่ข้าง ๆ เพจจะ return ข้อมูล แล้ว SvelteKit ส่งให้เพจเป็น data prop เพราะ load รันก่อนเพจ render (บน server สำหรับ request แรก) ข้อมูลจึงอยู่ใน HTML ตั้งแต่แรก — ไม่มีอาการกระพริบตอนโหลด และดีต่อ SEO

src/routes/blog/[slug]/+page.js
export async function load({ params, fetch }) {
const res = await fetch(`/api/posts/${params.slug}`);
return { post: await res.json() }; // becomes the `data` prop
}
src/routes/blog/[slug]/+page.svelte
<script>
let { data } = $props(); // data.post is here
</script>
<h1>{data.post.title}</h1>
flowchart LR
  req["request /blog/hello"] --> load["load({ params, fetch })"]
  load --> data["return { post }"]
  data --> page["+page.svelte ได้ data prop"]
  page --> html["rendered HTML"]
load รันก่อน แล้วเพจ render พร้อมข้อมูล

load มีสองแบบ และการเลือกให้ถูกคือการตัดสินใจสำคัญ:

  • +page.js — universal load รันบน server สำหรับ render แรก แล้วรันใน browser สำหรับ client-side navigation ครั้งต่อ ๆ ไป ใช้กับข้อมูลที่ fetch ที่ไหนก็ปลอดภัย (public API ผ่าน fetch ที่ได้มาให้)
  • +page.server.js — server-only load รันบน server เสมอ ไม่เคยรันใน browser ใช้เมื่อโค้ดต้องอยู่ฝั่ง server: query database ตรง ๆ, secret, private environment variable, อ่าน cookie สำหรับ auth ข้อมูลที่ return ต้อง serializable (เพราะถูกส่งไป client)
// src/routes/dashboard/+page.server.js — server-only
import { db } from '$lib/server/db';
export async function load({ locals }) {
// locals.user was set by a hook; db + secrets never reach the browser.
return { projects: await db.projectsFor(locals.user.id) };
}

กฎง่าย ๆ: ถ้าแตะ database, secret หรือ private env ให้ไว้ใน +page.server.js นอกนั้น +page.js ก็พอ และยังให้ client navigation refetch ได้โดยไม่ต้องวิ่งไป server เทียบกันเต็ม ๆ:

+page.js (universal)+page.server.js (server-only)
รันที่ไหนserver (render แรก) แล้ว browser (client nav)server เสมอ — ไม่เคยส่งไป browser
แตะ database / secret / private env ได้ไหมไม่ได้ — โค้ดนั้นจะถูกส่งไป browserได้
fetchfetch พิเศษของ SvelteKit (จัดการ relative URL, forward cookie ตอน SSR)fetch พิเศษตัวเดียวกัน บวกเรียก server ตรง ๆ ได้
cookies, localsไม่มีมี
ค่าที่ returnต้อง serializable หรือใช้ตรง ๆ ใน component tree ได้เลยต้อง serializable (เข้ารหัสด้วย devalue ไปที่ client)
ใช้กับ +layout ด้วย+layout.js+layout.server.js

load function รับ event object ที่มี field ที่มีประโยชน์: params (route params), fetch, url (URL ที่ร้องขอ), setHeaders, cookies (server load เท่านั้น), locals (server load เท่านั้น), parent (เพื่อ await ข้อมูลจาก parent layout) และ depends (เพื่อลงทะเบียน invalidation key ของตัวเอง)

เรียก depends('app:posts') ใน load จะผูกการรันซ้ำของ load นั้นกับ key นั้น ทีหลังเรียก invalidate('app:posts') (จาก $app/navigation) จะรัน load ทุกตัวที่เรียก depends ด้วย key นั้นซ้ำ — โดยไม่ reload ทั้งหน้า invalidateAll() จะรัน load function ทุกตัว ของเพจปัจจุบัน ไม่สนใจ key เลย

load ใน +layout.js / +layout.server.js ให้ข้อมูลกับ layout และ ทุก child page — เหมาะกับสิ่งที่ทุกเพจต้องใช้ (current user, site settings) load ของ child page อ่านข้อมูลนั้นได้ผ่าน parent() แล้วค่าที่ child return จะถูก merge ทับข้างบนสำหรับ data prop

src/routes/settings/+layout.server.js
export function load({ depends }) {
depends('app:site'); // this layout's data can be re-run on demand
return { site: { name: 'Acme' } };
}
src/routes/settings/+page.server.js
export async function load({ parent, depends }) {
depends('app:settings');
const { site } = await parent(); // merge in the parent layout's data
return { site, theme: 'dark' }; // data prop = { site, theme }
}

พิสูจน์ depends และ parent() โดยเรียก load ทั้งสองตัวตรง ๆ ด้วย fake event แบบเดียวกับที่ unit-test มัน (ไม่ต้องใช้ browser):

src/routes/settings/load.test.ts
import { test, expect } from 'vitest';
import { load as layoutLoad } from './+layout.server.js';
import { load as pageLoad } from './+page.server.js';
test('+layout.server.js registers a depends key and returns site data', () => {
const deps: string[] = [];
const data = layoutLoad({ depends: (k: string) => deps.push(k) } as any);
expect(deps).toEqual(['app:site']);
expect(data).toEqual({ site: { name: 'Acme' } });
});
test('+page.server.js merges the parent layout data via parent()', async () => {
const deps: string[] = [];
const data = await pageLoad({
depends: (k: string) => deps.push(k),
parent: async () => ({ site: { name: 'Acme' } }),
} as any);
expect(deps).toEqual(['app:settings']);
expect(data).toEqual({ site: { name: 'Acme' }, theme: 'dark' });
});
✓ +layout.server.js registers a depends key and returns site data
✓ +page.server.js merges the parent layout data via parent()

จาก component คุณจะเรียก invalidate('app:settings') หลัง action บางอย่างเพื่อให้ load ตัวนั้นตัวเดียวรันซ้ำ หรือ invalidateAll() เพื่อ refresh ข้อมูลทั้งเพจ:

<script>
import { invalidate, invalidateAll } from '$app/navigation';
</script>
<button onclick={() => invalidate('app:settings')}>Refresh settings only</button>
<button onclick={() => invalidateAll()}>Refresh everything</button>

ตัวอย่างข้างบนทุกอันจะ await ข้อมูลก่อน return เสมอ — ทั้งเพจจึงรอส่วนที่ช้าที่สุด SvelteKit ทำ streaming แทนได้: return promise โดย ไม่ await แล้วใช้ {#await} ใน template เพื่อรับมัน HTML แรกจะถูกส่งทันทีที่ส่วนที่ await แล้วพร้อม ส่วนที่ stream จะเติมเข้ามาทีหลังเมื่อ promise ของมัน resolve — time-to-first-byte ต่ำลงสำหรับส่วนที่สำคัญ โดยไม่ต้องรอส่วนที่ไม่สำคัญ

src/routes/blog/[slug]/+page.server.js
/** @param {string} slug */
function loadComment(slug) {
return new Promise((resolve) => {
setTimeout(() => resolve(`a slow comment on ${slug}, arrived late`), 2000);
});
}
export function load({ params }) {
return {
comment: loadComment(params.slug), // NOT awaited — a streamed promise
};
}
src/routes/blog/[slug]/+page.js
export async function load({ params, fetch, data }) {
const res = await fetch(`/api/posts/${params.slug}`);
return {
post: await res.json(),
comment: data.comment, // forward the server load's streamed promise unchanged
};
}
src/routes/blog/[slug]/+page.svelte
<script>
let { data } = $props(); // data.post and data.comment, merged from both load files
</script>
<h1>{data.post.title}</h1>
{#await data.comment}
<p>Loading comment…</p>
{:then comment}
<p>{comment}</p>
{/await}

พิสูจน์: สคริปต์ Node อ่าน response เป็น stream แล้วจับเวลาแต่ละ chunk เทียบกับ setTimeout 2 วินาทีใน loadComment

$ node stream-timing.mjs
t=36ms first bytes arrived
t=36ms saw "loading comment…" in the stream (pending state)
t=2034ms saw resolved comment text in the stream
t=2035ms stream ended

แบบเดียวกันด้วย curl --no-buffer ธรรมดา เช็คว่ามี byte มาถึงกี่ byte แล้วหลังจากผ่านไปแค่ 300ms:

$ ( time curl --no-buffer -s http://localhost:5196/blog/hello-world -o /tmp/out.html ) &
$ sleep 0.3 && wc -c /tmp/out.html && grep -o "loading comment" /tmp/out.html
3825 /tmp/out.html
loading comment
$ wait
curl --no-buffer -s http://localhost:5196/blog/hello-world -o 0.01s user 0.01s system 0% cpu 2.019 total
$ grep -o "a slow comment, arrived late" /tmp/out.html
a slow comment, arrived late

ที่ 300ms มี 3825 byte — รวม markup สถานะ pending loading comment… — มาถึงแล้ว ขณะที่ request ทั้งก้อนจบที่ 2.019s ตอน promise ของ comment resolve เท่านั้น ตัว shell ของเพจไม่ได้รอส่วนที่ช้าเลย

คอร์สนี้ใช้ page, navigating, และ updated จาก $app/state ทุกที่ (เห็นแล้วในบท routing ตรง page.error) module นั้นต้องการ Svelte 5 โค้ดเก่า — และโค้ดที่ AI generate จำนวนมากที่เทรนจากตัวอย่างก่อน 2.12 — import สิ่งเดียวกันจาก $app/stores แทน ซึ่งเป็น store ที่ต้องอ่านด้วย prefix $

<!-- legacy: $app/stores (deprecated) -->
<script>
import { page } from '$app/stores';
</script>
<h1>{$page.url.pathname}</h1>
<!-- current: $app/state (Svelte 5) -->
<script>
import { page } from '$app/state';
</script>
<h1>{page.url.pathname}</h1>

$app/stores ยังทำงานอยู่ — ยังไม่ถูกลบ — แต่ source ของ SvelteKit ที่ติดตั้งอยู่บอกอนาคตของมันไว้ตรง ๆ ที่ export เลย:

$ grep -n "deprecated" node_modules/@sveltejs/kit/src/runtime/app/stores.js
9: * @deprecated Use `$app/state` instead (requires Svelte 5, see docs for more info)
33: * @deprecated Use `page` from `$app/state` instead (requires Svelte 5, see docs for more info)
50: * @deprecated Use `navigating` from `$app/state` instead (requires Svelte 5, see docs for more info)
65: * @deprecated Use `updated` from `$app/state` instead (requires Svelte 5, see docs for more info)

migration guide บอกไว้ตรง ๆ: “$app/stores is deprecated and subject to be removed in SvelteKit 3” เวลาอ่านโค้ดที่ generate มาแล้วเจอ import $page จาก $app/stores ให้จำไว้ว่านั่นคือ pattern เก่า ไม่ใช่ฟีเจอร์คนละตัว

เวลารีวิวโค้ด load ที่ generate มา เช็คสิ่งเหล่านี้:

  • await ค่าที่เพจไม่ต้องใช้ในการ render ครั้งแรก (comment thread, “related posts”) แทนที่จะ return promise แบบไม่ await ให้ {#await} stream หรือเปล่า?
  • import page/navigating/updated จาก $app/stores (เลิกใช้ตั้งแต่ Kit 2.12 พิสูจน์แล้วข้างบน) แทน $app/state หรือเปล่า?
  • server load return สิ่งที่ไม่ serializable (class instance, function) แทนข้อมูลธรรมดาหรือเปล่า?
  • load ของเพจเอง fetch ข้อมูลที่ load ของ parent layout fetch ไปแล้วซ้ำ แทนที่จะอ่านผ่าน parent() หรือเปล่า?
  • depends() ถูกเรียกด้วย key ที่ไม่มีใครเรียก invalidate() ด้วยเลยหรือเปล่า — เป็นสาย wiring ที่ตายแล้วแต่หน้าตาเหมือนเปิด selective refresh?

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

<script>
import { page } from '$app/stores';
let pathname = $derived($page.url.pathname);
</script>
<h1>{pathname}</h1>
เฉลย

โค้ดนี้ compile และรันได้วันนี้จริง — $app/stores ยังไม่ถูกลบ แต่มันคือ module ที่เลิกใช้แล้ว: source ของ SvelteKit เองใส่ @deprecated Use \page` from `$app/state` insteadไว้ทุก export และ migration guide บอกว่า$app/stores"is deprecated and subject to be removed in SvelteKit 3" ทางแก้คือ importpageจาก$app/stateแทน แล้วตัด prefix$ออกทั้งหมด —pageจาก$app/stateเป็น reactive object อยู่แล้ว (backed ด้วย$state) ไม่ใช่ store จึง page.url.pathname` คือทั้งหมด ไม่ต้องใช้ syntax subscription

load function คือ data contract ของ route ไม่ใช่แค่ fetch call ที่แปะไว้ใน component ค่าที่ return จะกลายเป็น data prop ที่ merge มาจากทั้งสาย: +layout.server.js → +layout.js (ผ่าน parent()) → +page.server.js → +page.js ข้อมูลชิ้นไหนจะ block การ render แรกหรือไม่ เป็นตัวเลือกของคุณต่อ field: await มันเพื่อการันตีว่าอยู่ใน HTML แรก หรือ return promise เปล่า ๆ ให้ {#await} stream เข้ามาทีหลัง depends() กับ invalidate() ก็เป็นแนวคิดเดียวกันแต่ใช้กับการรันซ้ำ: load ไม่รู้เองว่าข้อมูลของมัน stale เมื่อไร มันจึงประกาศ key ที่มันขึ้นกับ แล้วให้ที่อื่นตัดสินใจว่าจะ invalidate key นั้นเมื่อไร

ได้อะไรประโยชน์ต้นทุน
+page.js (universal)รันใน browser ด้วย — client nav refetch โดยไม่ต้องวิ่งไป serverแตะ secret, database, private env ไม่ได้
+page.server.js (server-only)เข้าถึง server เต็มที่ (db, secret, cookie)client navigation ทุกครั้งยังต้องวิ่งไป server
stream promise ที่ไม่ awaittime-to-first-byte ต่ำลงสำหรับส่วนที่สำคัญผู้อ่านเห็น loading state; error ข้างในต้องจัดการใน {#await} (หรือ <svelte:boundary>) แทนที่จะทำให้ทั้งเพจล้ม
depends() + invalidate()รันซ้ำแบบเลือกได้ แทนที่จะ invalidateAll() ทั้งหมดkey ที่ไม่มีใคร invalidate เลยคือโค้ดที่ตายแล้วแต่หน้าตาเหมือนทำอะไรบางอย่าง
  • “universal load ก็แค่ server load ที่ด้อยกว่า” มันเป็นแบบเดียวที่รันใน browser ด้วย — client-side navigation ข้าม server round-trip ไปเลยสำหรับมัน พิสูจน์แล้วในตารางเทียบข้างบน
  • “ค่าทุกตัวของ load ต้อง await ก่อน return เสมอ” พิสูจน์แล้วข้างบน: promise ที่ไม่ await จะ stream — shell render ทันทีแล้ว {#await} เติมส่วนที่เหลือทีหลัง
  • “$app/stores กับ $app/state คือชื่อสองชื่อของสิ่งเดียวกัน” $app/stores เลิกใช้แล้ว (พิสูจน์แล้วข้างบน จาก source ของ Kit เองและ migration guide) — page ของ $app/state เป็น reactive object ธรรมดา ไม่ใช่ store จึงอ่านโดยไม่ต้องมี $
  • “load ของ child page ต้อง fetch สิ่งที่ layout load ไปแล้วซ้ำ” parent() return ข้อมูลที่ resolve แล้วของ parent ให้แทน
เพจ SvelteKit รับข้อมูลจาก load function ยังไง
ต้องใช้ +page.server.js แทน +page.js เมื่อไร
promise comment ที่ไม่ await ให้เพจทำอะไรได้
// src/routes/blog/[slug]/+page.server.js
export function load({ params }) {
  return {
    comment: loadComment(params.slug),   // returned WITHOUT awaiting
  };
}
load ใน +layout.js มีประโยชน์กับอะไร และ child page อ่านข้อมูลนั้นยังไง
เรียก depends('app:settings') ใน load function ทำอะไร
อะไรคือความจริงเกี่ยวกับ pattern นี้
<script>
  import { page } from '$app/stores';
</script>
<h1>{$page.url.pathname}</h1>