この記事は CAMPFIRE Advent Calendar 2025 2日目の参加記事です。
はじめに
SvelteKit でコミュニティ機能を提供するWebアプリケーションの開発を始めたばかりの頃、いくつかのポイントで悩んだ経験を共有します。同じように迷っている方の参考になれば幸いです。
使用技術スタック
本記事で使用しているmagma-webプロジェクトの技術スタックは以下の通りです:
- フロントエンド: SvelteKit 2.16.0 (Svelte 5.0.0)
- ビルドツール: Vite 6.2.6
前提条件
本記事では、以下の前提で開発を行っています:
- Vue.jsなどjsのフレームワークの経験は多少ある状態で開始
- SSR = false: サーバーサイドレンダリングは無効化
- クライアントサイドのみで動作: ブラウザ上でのみ実行される
本記事では、以下の3つのポイントについて、実際に躓いた経験と解決策を紹介します:
- Svelteの基本構造とロジックの配置
- 記号など省略記号を使った処理の意味
- 受け渡しをどうするか(Context vs Props)
1. Svelteの基本構造とロジックの配置で迷った話
SvelteKitでは、ファイルベースのルーティングシステムが採用されています。最初は、どのファイルに何を書くべきか、ロジックをどこに置くべきかで迷いました。
SvelteKitの基本構造
SvelteKitでは、src/routesディレクトリ配下のファイル構造がそのままURLのルーティングになります。
src/routes/
├── +page.svelte # ページコンポーネント
├── +layout.svelte # レイアウトコンポーネント
├── (main)/ # ルートグループ
│ ├── +layout.svelte
│ └── users/
│ ├── +page.svelte
│ └── [userId]/
│ ├── +layout.svelte
│ └── +page.svelte
└── (user)/
└── users/
└── [userId]/
└── products/
└── [productId]/
└── +page.svelte
ファイルパスとURLの対応関係
SvelteKitでは、ファイルパスがそのままURLになります。[userId]や[productId]のような角括弧は動的パラメータを表します。
また、(main)や(user)のように括弧で囲まれたディレクトリはルートグループと呼ばれ、URLには反映されません。これは、ルーティングを整理するためのまとめ的な意味で使用されます。
| ファイルパス | URLの例 | |
|---|---|---|
src/routes/users/[userId]/+page.svelte |
/users/123 |
123はuserIdパラメータ |
src/routes/(main)/users/[userId]/+page.svelte |
/users/123 |
(main)はURLに反映されない |
src/routes/(user)/users/[userId]/products/[productId]/+page.svelte |
/users/123/products/456 |
パラメータが複数の場合も可能 |
躓きポイント
- 階層と記号が多くてURLと修正ファイルが一致させるまで時間がかかる: SvelteKitのファイルベースルーティングでは、ディレクトリ構造がそのままURLになるため、どのファイルを修正すべきか判断するのに時間がかかりました
-
+page.svelteと+layout.svelteの違いがわからない: どちらに何を書くべきか迷いました-
+page.svelte: そのルートのページコンテンツ -
+layout.svelte: そのルートと子ルートで共通のレイアウト
-
-
ロジックをどこに置くべきかわからない: データ取得や状態管理のロジックを
+page.svelteに直接書くか、+layout.svelteに書くか、別のコンポーネントファイルに分けるか迷いました
すべてを+page.svelteに書くと、ページコンポーネントが肥大化し、ロジックと表示が混在して再利用が難しくなるのが予測される。そのため、+page.svelteや+layout.svelteにはビューの処理だけを記載し、ロジック(データ取得など)は別ファイルに分ける方針にしました。
2. 記号など省略記号を使った処理の意味がわからない
Svelte 5では、リアクティビティシステムが大幅に変更され、従来の$:(リアクティブステートメント)に加えて、$effect、$state、$derived、$propsなどの新しいAPIが導入されました。
-
$:- リアクティブステートメント(Svelte 4以前から存在、legacy mode)- トップレベルのステートメント(ブロックや関数の外)に
$:プレフィックスを付けると、依存する値が変更されたときに自動的に再実行される - Svelte 5のrunes modeでは
$derivedと$effectが推奨される - 公式ドキュメント: Reactive statements(日本語)
- トップレベルのステートメント(ブロックや関数の外)に
-
$effect- Side Effectの処理(Svelte 5で導入) -
$state- リアクティブな状態(Svelte 5で導入) -
$derived- 派生状態(Svelte 5で導入) -
$props- プロパティ(Svelte 5で導入) -
$store- ストアの購読(例:$page,$userStore)
補足: Side Effectとは?
Side Effectとは、関数や式の実行によって、その関数や式の戻り値以外に影響を与える処理のことです。具体的には以下のような処理が該当します:
- DOM操作(要素のフォーカス、スクロールなど)
- API呼び出し(サーバーへのリクエスト)
- タイマーの設定(
setInterval、setTimeoutなど)- イベントリスナーの登録
- ログ出力(
console.logなど)
躓きポイント
$:と$effectは違うもの?
スベルトのついて調べていると$: を利用しているコードがいくつか目に入りました。
$:は以前からあった機能で、Svelte 5では$derivedと$effectが推奨されています。
<script>
let count = 0;
// 従来の方法(legacy mode)
$: {
console.log('count changed:', count);
}
$: doubled = count * 2;
</script>
Svelte 5のrunes modeでは、以下のように書き換えられます:
<script>
let count = $state(0);
// $derived: 派生状態(計算された値)
const doubled = $derived(count * 2);
// $effect: Side Effectの処理(DOM操作、API呼び出しなど)
$effect(() => {
console.log('count changed:', count);
});
</script>
$propsとexport letの違いがわからない
Svelte 5では$props()が推奨されています。$props()を使うことで、型安全性が向上し、デフォルト値の設定も簡単になります。
<script>
export let userId: string; // 従来の方法
let { userId }: Props = $props(); // Svelte 5の方法
</script>
3. 受け渡しをどうするか(Context vs Props)で悩んだ話
Webアプリケーションの開発中、現在のユーザー情報、商品情報、認証状態、カートの状態などを管理する必要がありました。各コンポーネントで個別に状態を管理していたため、グローバルな状態が一元管理されず、同じデータを複数の場所で管理し、データの整合性が取れない問題が発生していました。
躓きポイント
各コンポーネントで個別に状態を管理していたため、以下の問題が発生していました:
- 状態が散在し、同じデータを複数の場所で管理している
- データの重複取得が発生し、同じAPIを複数回呼び出している
- 状態の更新がバラバラで、他のコンポーネントに反映されない
- データの受け渡し方法が統一されていない(Props、Context、Store、直接API呼び出しなどが混在)
この状態では、データの流れが追いにくく、バグの原因になりやすいと感じました。
試行錯誤と学び
最初はすべてをPropsで受け渡していましたが、Propsのバケツリレーが発生し、深い階層のコンポーネントに渡すのが大変でした。次にすべてをContextで管理しようとしましたが、Contextが多くなりすぎて管理が難しくなりました。Storeで管理することも検討しましたが、Store間の依存関係が複雑になる問題がありました。
// lib/context/user-context.ts
import { setContext, getContext } from 'svelte';
import type { Writable } from 'svelte/store';
const USER_CONTEXT_KEY = Symbol('user');
export function setUserContext(user: Writable<User>) {
setContext(USER_CONTEXT_KEY, user);
}
export function getUserContext(): Writable<User> {
return getContext(USER_CONTEXT_KEY);
}
<!-- src/routes/(main)/+layout.svelte -->
<script>
import { setUserContext } from '$lib/context/user-context';
import { useQuery } from '@tanstack/svelte-query';
import { writable } from 'svelte/store';
// データ取得
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: () => getUsers(),
});
// Contextに設定
if (users) {
const userStore = writable(users[0]);
setUserContext(userStore);
}
</script>
<slot />
結論:
- Contextは文脈ごとに分ける: User、Product、Cartなど、文脈ごとにContextを分け、関連するContextは同じファイルにまとめる
- ContextとPropsの使い分け: Contextは複数のコンポーネントで共有するデータ、Propsは特定のコンポーネントに渡すデータ
- レイアウトでContextを設定: ルーティングに応じて適切なレイアウトでContextを設定することで、データの流れが明確になる
- 一貫した方針の重要性: 一貫した方針を設けることで、コードの可読性が向上し、後から見返した際も理解しやすくなる
- 過剰な抽象化を避ける: グローバルな状態管理ライブラリは必要になるまで使わず、SvelteKitの標準機能で十分な場合が多い
まとめ
SvelteKitで開発を始めたばかりの頃、以下の4つのポイントで躓きました:
-
Svelteの基本構造とロジックの配置
- ファイルベースルーティングでは、ディレクトリ構造がそのままURLになるため、どのファイルを修正すべきか判断するのに時間がかかった
-
+page.svelteと+layout.svelteの使い分けが重要(+page.svelteはページコンテンツ、+layout.svelteは共通レイアウト) - ロジックと表示を適切に分離することで、コードの可読性が向上し、再利用が容易になる
-
記号など省略記号を使った処理の意味
- Svelte 5では、従来の
$:(リアクティブステートメント)に加えて、$effect、$state、$derived、$propsなどの新しいAPIが導入された - Svelte 5のrunes modeでは
$derivedと$effectが推奨される($:はlegacy mode) -
$derivedは派生状態(計算された値)、$effectはSide Effectの処理に使い分ける -
$props()はexport letよりも型安全性が向上し、デフォルト値の設定も簡単になる - ストアは
$pageや$userStoreのように$プレフィックスで自動購読できる
- Svelte 5では、従来の
-
受け渡しをどうするか(Context vs Props)
- すべてをPropsで受け渡すとPropsのバケツリレーが発生し、すべてをContextで管理するとContextが多くなりすぎて管理が難しくなる
- Contextを文脈ごとに分け、レイアウトでContextを設定する方針で実装することで、データの流れが明確になる
- Contextは複数のコンポーネントで共有するデータ、Propsは特定のコンポーネントに渡すデータとして使い分ける
- 一貫した方針を設けることで、コードの可読性が向上し、過剰な抽象化を避けることができる
どのポイントも、一貫した方針を設けることが重要だと感じました。
まだまだ勉強中の身ですが、同じように迷っている方の参考になれば幸いです。