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(本文)はタグの間に直接書き、headerfooter{#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を渡したいとき」 がスニペットの出番です。

← トップページに戻る