Svelte 5のスニペット({#snippet}と{@render})を整理した話
Svelte 5ではスロット(slot)が廃止され、代わりにスニペットという仕組みになりました。{#snippet} で定義して {@render} で呼び出します。
Svelte 4との対応関係
| Svelte 4 | Svelte 5 |
|---|---|
<slot /> |
{@render children()} |
<slot name="header" /> |
{@render header()} |
<svelte:fragment slot="header"> |
{#snippet header()} |
| 型なし | Snippet 型でTypeScriptが効く |
① 基本的な使い方
同じコンポーネント内でスニペットを定義して使い回す例です。
<script lang="ts">
let items = ['りんご', 'バナナ', 'みかん'];
</script>
{#snippet listItem(item: string)}
<li style="color: blue;">{item}</li>
{/snippet}
<ul>
{#each items as item}
{@render listItem(item)}
{/each}
</ul>
{#snippet 名前(引数)} で定義して、{@render 名前(引数)} で呼び出します。
② children スニペット(旧 <slot /> の代替)
親コンポーネントのタグの間に書いた内容は、自動的に children スニペットとして渡されます。
子コンポーネント(Card.svelte)
<!-- src/lib/components/Card.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte';
type Props = {
children: Snippet;
};
let { children }: Props = $props();
</script>
<div style="border: 1px solid #ccc; padding: 16px; border-radius: 8px;">
{@render children()}
</div>
親コンポーネントで使う
<Card>
<p>カードの中身です</p>
<button>ボタン</button>
</Card>
ポイント:children だけは特別扱いです。タグの間に直接書いた内容が自動的に children として渡されるため、親側で {#snippet children()} と書く必要はありません。children 以外の名前のスニペットは {#snippet} で明示的に定義する必要があります。
③ 名前付きスニペット(旧 named slot の代替)
複数の差し込み口を持つコンポーネントも作れます。
子コンポーネント(Dialog.svelte)
<!-- src/lib/components/Dialog.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte';
type Props = {
header: Snippet;
children: Snippet;
footer: Snippet;
};
let { header, children, footer }: Props = $props();
</script>
<div style="border: 1px solid #333; padding: 16px;">
<div style="font-weight: bold; border-bottom: 1px solid #ccc; padding-bottom: 8px;">
{@render header()}
</div>
<div style="padding: 16px 0;">
{@render children()}
</div>
<div style="border-top: 1px solid #ccc; padding-top: 8px; text-align: right;">
{@render footer()}
</div>
</div>
親コンポーネントで使う
<Dialog>
{#snippet header()}
<span>タイトル</span>
{/snippet}
<p>ここが本文です。childrenスニペットになります。</p>
{#snippet footer()}
<button>キャンセル</button>
<button>OK</button>
{/snippet}
</Dialog>
children(本文)はタグの間に直接書き、header と footer は {#snippet} で明示的に定義します。
実務での使用シーン
レイアウト系コンポーネント
カード・モーダル・ページレイアウトなど「外枠は共通、中身は呼び出し元が決める」パターンが最も多いです。
<Card>
<p>商品説明</p>
</Card>
<Modal>
{#snippet header()}確認{/snippet}
<p>本当に削除しますか?</p>
{#snippet footer()}
<button>キャンセル</button>
<button>削除</button>
{/snippet}
</Modal>
テーブルのセル表示をカスタマイズする
汎用テーブルコンポーネントで列ごとに表示方法を変えたいとき。データは共通、見た目だけ呼び出し元が制御できます。
<Table {data}>
{#snippet cell(row)}
<td>{row.name}</td>
<td style="color: {row.stock > 0 ? 'green' : 'red'}">
{row.stock > 0 ? '在庫あり' : '在庫なし'}
</td>
<td><button onclick={() => handleEdit(row)}>編集</button></td>
{/snippet}
</Table>
ローディング・エラー・空状態の切り替え
<DataLoader {promise}>
{#snippet loading()}
<Spinner />
{/snippet}
{#snippet error(message)}
<p style="color: red">{message}</p>
{/snippet}
{#snippet empty()}
<p>データがありません</p>
{/snippet}
{#snippet children(data)}
<ul>
{#each data as item}<li>{item.name}</li>{/each}
</ul>
{/snippet}
</DataLoader>
同一コンポーネント内での繰り返し排除
コンポーネントを分けるほどではないが、同じマークアップが繰り返されるとき。
{#snippet fieldRow(label: string, value: string)}
<tr>
<th>{label}</th>
<td>{value}</td>
</tr>
{/snippet}
<table>
{@render fieldRow('名前', user.name)}
{@render fieldRow('メール', user.email)}
{@render fieldRow('登録日', user.createdAt)}
</table>
storeやpropsとの使い分け
| 問題 | 解決手段 |
|---|---|
| 状態を共有したい | store / props |
| 見た目(マークアップ)を差し込みたい | Snippet |
| 外枠は共通・中身は変えたい | Snippet(childrenパターン) |
| 複数の差し込み口が必要 | 名前付きSnippet |
「データではなくUIを渡したいとき」 がスニペットの出番です。