Svelte 5のリアクティビティ($state・$derived・$effect)を整理した話

Svelte 5からリアクティビティの仕組みが「ルーン(Rune)」という新しい方式に変わりました。 $state$derived$effect の3つを中心に、実際に動かしながら挙動を整理します。

Svelte 4との違い

Svelte 4までは let で宣言するだけで変数がリアクティブになりました。Svelte 5では明示的にルーンを使う必要があります。

<!-- Svelte 4 -->
<script>
  let count = 0;  // これだけでリアクティブ
</script>

<!-- Svelte 5 -->
<script lang="ts">
  let count = $state(0);  // $stateで明示的に宣言
</script>

3つのルーンの役割

ルーン 役割 近いイメージ
$state 変化する値を定義する ref() (Vue) / useState (React)
$derived 他の値から計算される値 computed (Vue) / useMemo (React)
$effect 値の変化に反応して副作用を実行 watch (Vue) / useEffect (React)

$state — 変化する値を定義する

<script lang="ts">
  let count = $state(0);
</script>

<p>カウント: {count}</p>
<button onclick={() => count++}>+1</button>
<button onclick={() => count--}>-1</button>

$state(初期値) で宣言した変数を変更するだけでUIが自動的に更新されます。


$derived — 他の値から計算される値

<script lang="ts">
  let count = $state(0);
  let doubled = $derived(count * 2);
  let message = $derived(count >= 10 ? '10以上です' : 'まだ10未満です');
</script>

<p>カウント: {count}</p>
<p>2倍: {doubled}</p>
<p>状態: {message}</p>

$derived の中で参照している $state が変わると自動的に再計算されます。計算ロジックをテンプレートに直接書かず $derived にまとめることでコードが読みやすくなります。$derived の中に別の $derived を使うこともできます。


$effect — 値の変化に反応して副作用を実行する

<script lang="ts">
  import { untrack } from 'svelte';

  let count = $state(0);
  let history = $state<number[]>([]);

  $effect(() => {
    history = [...untrack(() => history), count];
  });
</script>

<p>履歴: {history.join(' → ')}</p>

$effect は内部で読んだ $state$derived の値が変わると自動的に再実行されます。

躓きポイント:無限ループ

以下のように書くと無限ループになります。

// ❌ 無限ループ
$effect(() => {
  history = [...history, count];  // historyを読んでhistoryを書き換える
});

$effect は内部で読んだ $state をすべて追跡します。history を読んで書き換えると、その書き換えが再び $effect を起動して無限ループになります。

count変化 → $effect実行 → history書き換え → $effect再実行 → history書き換え → ...

解決策:untrack を使う

// ✅ untrackで囲んだ値は追跡されない
$effect(() => {
  history = [...untrack(() => history), count];
});

untrack で囲んだ値は「読んでも追跡しない」ようになります。これにより「count が変わったときだけ実行、history の変化では再実行しない」という意図通りの動きになります。


$effect のクリーンアップ

タイマーやイベントリスナーのような「後片付けが必要な処理」は $effect の中で関数を return します。

<script lang="ts">
  let count = $state(0);
  let autoCount = $state(false);

  $effect(() => {
    if (!autoCount) return;

    const timer = setInterval(() => {
      count++;
    }, 1000);

    return () => clearInterval(timer);  // クリーンアップ
  });
</script>

<button onclick={() => autoCount = !autoCount}>
  {autoCount ? '自動停止' : '自動カウント開始'}
</button>

autoCounttrue になるとタイマーが起動して1秒ごとに count が増えます。autoCountfalse に戻ると return した関数が実行されてタイマーが停止します。クリーンアップを書かないとタイマーが残り続けてメモリリークになります。


まとめ

<script lang="ts">
  import { untrack } from 'svelte';

  // $state:変化する値
  let count = $state(0);

  // $derived:他の値から計算される値
  let doubled = $derived(count * 2);

  // $effect:値の変化に反応して副作用を実行
  $effect(() => {
    // countを追跡・historyは追跡しない
    history = [...untrack(() => history), count];
  });

  // $effect のクリーンアップ
  $effect(() => {
    if (!autoCount) return;
    const timer = setInterval(() => count++, 1000);
    return () => clearInterval(timer);  // 後片付け
  });
</script>
ポイント 内容
$state 明示的に宣言しないとリアクティブにならない(Svelte 4との違い)
$derived 計算ロジックをテンプレートから分離できる
$effect 内部で読んだ $state すべてを自動追跡する
untrack 読んでも追跡しない。無限ループ防止に使う
クリーンアップ $effectreturn した関数が後片付けとして実行される
← トップページに戻る