Svelte 5マイグレーションでハマったところ


Svelte 4で作っていたSPAのコンポーネントをSvelte 5のRuneを使った書き方にマイグレーションしていて、個人的にハマった（苦戦した）ポイントが大きく分けて3つありました。
なお、SvelteKitは使っていない、ただのSvelteです。

- 再描画・再評価を引き起こすトリガーの違い
- ストアとRuneの違い
- ストアに持たせた参照の扱い

## 再描画・再評価を引き起こすトリガーの違い

### 実行時に参照されたものがトリガーとなる

Svelte 4では、テンプレート部分や `$:` で書いたリアクティブな部分では、その中で現れた変数の値が変わると再描画・再評価されていました。

例えば、次のブロックでは `isCounter1` 、 `counter1` 、`counter2` が現れます。これらの値のいずれかが変わるとこのブロックは再評価されていました。

```js
// Svelte 4
let isCounter = true;
let counter1 = 0;
let counter2 = 0;

$: {
  let value;
  if (isCounter1) {
    console.log("counter1");
    value = counter1;
  } else {
    console.log("counter2");
    value = counter2;
  }
  console.log(value);
}
```

Svelte 5になると、条件が少し変わって、前回の実行時に参照された変数の値が変わったときに再描画・再評価されるようになります。
先ほどのコードをSvelte 5のRuneを使って書き直してみます。

```js
// Svelte 5
let isCounter = $state(true);
let counter1 = $state(0);
let counter2 = $state(0);

$effect(() => {
  let value;
  if (isCounter1) {
    console.log("counter1");
    value = counter1;
  } else {
    console.log("counter2");
    value = counter2;
  }
  console.log(value);
})
```

この `$effect` ブロックは、最初に実行されたときは `isCounter1` を参照し、その値が `true` なので次に `counter1` を参照します。`counter2` は参照せずに終わります。

すると、このブロックが再評価されるトリガーは `isCounter1` と `counter1` です。これらの値が変わったときに再評価が行われます。逆に、`counter2` はトリガーではないので値が変わっても再評価されません。

ここで `isCounter1` が `false` に変わったとします。

`isCounter1` はトリガーなので先ほどのブロックが再度実行されます。`isCounter1` を参照し、今度は `false` なので次に `counter2` を参照します。すると、次に再評価を行うトリガーは `isCounter1` と `counter2` になります。
今度は `counter1` が変わっても再評価されません。

### そこから呼ばれた関数内で参照したものもトリガーとなる

ポイントはトリガーが実行されたときに決まるようになったことです。これが大きく影響してくるのが、次のようにブロック内で別の関数を呼び出した場合です。

以下はSvelte 4時代の公式ドキュメントに書かれていた例です。
Svelte 4ではリアクティブなブロックに直接登場する変数だけがトリガーとなるため、`total` を計算する部分のトリガーは `x` のみです。`y` の値が変わっても `total` は再計算されません。

```js
// Svelte 4
let x = 0;
let y = 0;

function yPlusAValue(value) {
  return value + y;
}

$: total = yPlusAValue(x);
```

これをSvelte 5のRuneを使って書き直してみます。

```js
// Svelte 5
let x = $state(0);
let y = $state(0);

function yPlusAValue(value) {
  return value + y;
}

let total = $derived(yPlusAValue(x));
```

`total` を計算する `$derived` が実行されると、まず `x` が参照され、次に呼び出された `yPlusAValue` 内で `y` が参照されます。Svelte 5では実行時にトリガーが収集されるため、次の再計算のトリガーは `x` と `y` の両方になります。

これは単純な例ですが、もっと複雑なコードになると、意識していないところでトリガーが増えていて、Svelte 4では行われなかった再評価が行われることがありました。

特に、`$effect` を使って副次的な処理を行っている場合は、必要以上に処理が行われたり、場合によってはトリガーを自分で更新してしまい、無限に再評価が行われてしまう場合もあって注意が必要です。
特に、Rune（例えば `$state`）で作られた値はコンポーネントを超えて参照できるので、呼び出す関数が別のファイルに定義された関数であったり、さらにそこから呼ばれた別の関数内でリアクティブな値を参照していたりすると意識していないところでトリガーが増えやすくなります。

### トリガーに含めたくなければ `untrack` を使う

`untrack` に渡した関数の中で参照したものはトリガーとして収集されません。

例えば、以下のように `yPlusAValue` の中で `untrack` を使って `y` を参照すると、`yPlusAValue` を呼び出しても `y` はトリガーとして収集されなくなり、Svelte 4のときと同じ動作をするようになります。

```js
import { untrack } from "svelte";

...

function yPlusAValue(value) {
  return value + untrack(() => y);
}
```

その関数の中では一切トリガーを収集させたくないのであれば、全体を `untrack` で囲むという手もありそうです。

```js
function yPlusAValue(value) {
  return untrack(() => {
    return value + y;
  });
}
```

## ストアとRuneの違い

Svelte 5では、拡張子 .svelte.js（TypeScriptなら .svelte.ts）のファイルにすることでSvelteコンポーネント以外のロジック部分においても、 `$state` や `$derived` などのRuneで作るリアクティブな値が使えるようになりました。

Svelte 4ではSvelteコンポーネントを跨がるロジック部分では `writable` などで作ったストアを用意しておき、Svelteコンポーネントでそのストアの値に `$` 付きでアクセスしていました。
Svelte 5になってもストアは残っていますが、Runeで作ったリアクティブな値に置き換えられるものもあります。

ところが、ただ単純に置き換えようとしたらハマりました。

### ストアはオブジェクト

ストアはオブジェクトです。同じものを他でも共通に参照することができます。

```js
const counter = writable(0);
const another = counter;

another.set(2);
```

この場合、 `counter` と `another` の型はどちらも `Writable<number>` であり、同じストアを参照しています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33397/c2394229-dea2-0f2c-0312-27e8d6dbc84e.png" width="250">

一方で、`$state` で作ったリアクティブな値はあくまでも値であってオブジェクトではありません。

```js
let counter = $state(0);
let another = counter;

another = 2;
```

`counter` も `another` も型は `number` です。
`another` には `counter` の現在値がコピーされただけで、`another` がリアクティブな値になるわけではありません。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33397/8800e95e-a46e-5abb-8771-04341ea99417.png" width="250">

ストアの時のように、 `counter` を他のロジック部分でも共有したいのであれば、クラスにくるんであげると良いです。

```js
class Counter {
  value = $state(0);
}

const counter = new Counter();
const another = counter;

another.value = 2;
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33397/8ac6822c-92b6-94b1-347b-6dedfd81cff3.png" width="250">

### クラスがやってくれること

クラスのインスタンスプロパティとしてリアクティブな値を持たせた場合、Svelteのコンパイラはそれをプライベートなフィールドと、その値にアクセスするゲッターとセッターに変換します。

上の `Counter` クラスのコンパイル結果はこのようになります。

```js
class Counter {
  #value = $.state(0);

  get value() {
    return $.get(this.#value);
  }

  set value(value) {
    $.set(this.#value, $.proxy(value));
  }
```

`Counter` インスタンスの `value` を参照すると、実際にはゲッター関数が呼ばれてリアクティブな値の現在値が返されます。また、 そのタイミングでリアクティブな値を参照したということがわかります。
これにより、テンプレート部分や `$effect` のブロックのようなリアクティブに再描画・再評価される部分から `value` を参照したときに、それが更新のトリガーであるということが記録されるようになります。

余談ですが、リアクティブな値をトリガーとして収集したり、値が更新されたらそれをトリガーとする部分を再評価する処理は、Svelteコンパイラがリアクティブな値へのアクセスを上記のように `$.get` と `$.set` に書き換えることで実現されているようです。
クラスの場合、ゲッター・セッター内でそれが行われているので、クラスインスタンスを利用する側は書き換えの必要がありません。つまり、そこではSvelteコンパイラによる変換は不要です。

やや無理矢理な例ですが、次の `Logic` クラスを定義するLogic.jsは拡張子が .svelte.js にはなっておらず、Svelteコンパイラはここには使われません。
それでも、`Logic` の `isEven` を呼び出した結果が、リアクティブに再描画されます。
（`isEven` の呼び出しにより、`NumberValue` の `value` のゲッターが呼ばれるため、リアクティブな値のトリガーとして収集されるためです）

```js:NumberValue.svelte.js
export class NumberValue {
  value = $state(0);
}
```

```js:Logic.js
import { NumberValue } from "./NumberValue.svelte";

export class Logic {
  constructor() {
    this.numberValue = new NumberValue();
  }

  update(value) {
    this.numberValue.value = value;
  }

  isEven() {
    return this.numberValue.value % 2 === 0;
  }
}
```

```svelte:App.svelte
<script>
  import { Logic } from "./Logic";

  const logic = new Logic();

  function updateValue() {
    logic.update(123);
  }
</script>
{#if logic.isEven()}
  偶数
{:else}
  奇数
{/if}

<button onclick={updateValue}>更新</button>
```

このように、コンポーネントではないロジック部分のソースコードでは、`$state` などのRuneを直接必要とする場合のみ、拡張子を .svelte.js にしてSvelteコンパイラを通せばいいようです。

### クラスを使わない場合

キモは値の参照と値の更新を関数として提供することなので、クラスを使わなくても同様のことを自分で行うこともできます。

例えば、こんな感じで、ゲッター関数や更新関数を持たせたオブジェクトにすることもできます。この例ではクラスと変わらないのであまり意味がありませんが、クラスにしなければいけないわけではないということで…。

```js
export function createCounter() {
  let value = $state(0);

  function increment() {
    value = value + 1;
  }
  
  return {
    get value() { return value; },
    increment,
  };
}
```

## ストアに持たせた参照の扱い

### Svelte 5は原則 "deeply reactive"

Svelte 5では原則として、リアクティブな変数にもともと参照していたのと同じオブジェクトが再代入されたとしても、値が変化したとは扱われない方針のようです。
ですが、 `$state` に持たせたオブジェクトや配列は特に何もしなければ "deeply reactive" なので、基本的には問題ないはずです。

例えば以下の例では、App.svelte で `updateUserName` と `updateUser` のどちらを実行しても、postやuserを見ているPostコンポーネントでの再評価は発生せず、コンソールのログは増えません。
ですが、nameを見ているUserコンポーネントは `updateUserName` によるnameの変化を検知して再描画されます。

```svelte:App.svelte
<script>
  import Post from "./Post.svelte";
  let post = $state({
    user: {
      name: "Alice"
    },
    content: "This is a content."
  });

  function updateUserName() {
    // userのプロパティを変更
    post.user.name = post.user.name + "+";
  }

  function updateUser() {
    // 同じものを代入
    post.user = post.user;
  }
</script>

<Post {post}/>
<button onclick={updateUserName}>updateUserName</button>
<button onclick={updateUser}>updateUser</button>
```

```svelte:Post.svelte
<script>
  import User from "./User.svelte";
  let { post } = $props();

  $effect(() => {
    post; // postが変わったら再評価してもらうために参照しておく
    console.log("post changed.");
  });

  $effect(() => {
    post.user; // post.userが変わったら再評価してもらうために参照しておく
    console.log("post.user changed.");
  });
</script>

<User user={post.user}/>
<div>{post.content}</div>
```

```svelte:User.svelte
<script>
  let { user } = $props();
</script>

<div>{user.name}</div>
```

## ストアは "deeply reactive" ではない

ストアは `set` でこれまでと同じオブジェクトをセットしたり、 `update` で同じオブジェクトを返すようにしても、値が変わったと判断されます（※数値や文字列はそうではありません）。ただし、ストアに入れた値はそのままでは "deeply reactive" にはありません。

先ほどの例のApp.svelteをストアを使うように書き直してみます。

```svelte:App.svelte
<script>
  import { writable } from "svelte/store";
  import Post from "./Post.svelte";
  const postStore = writable({
    user: {
      name: "Alice"
    },
    content: "This is a content."
  })

  function updateUserName() {
    postStore.update(post => {
      post.user.name = post.user.name + "+";
      return post;
    });
  }

  function updateUser() {
    postStore.update(post => post)
  }
</script>

<Post post={$postStore}/>
<button onclick={updateUserName}>updateUserName</button>
<button onclick={updateUser}>updateUser</button>
```

見た目の動作は変わりませんが、`updateUserName` でも `updateUser` でも、以下のコンソールログが表示され、 `post` 自体が変わったと認識されていることがわかります。

```
post changed.
post.user changed.
```

どうやら、`$` でストアの値を参照したときは、同じオブジェクトであっても値が変化したとみなすようです。

先ほどは `$state` からストアに書き換えましたが、実際のプロジェクトではSvelte 4でストアを使っていたところはそのままに、UserやPostのコンポーネントの方をRuneを使った書き方に変えていくことが多いと思いますが、それでも動作は変わらなくて良いですね！

ところが、ハマったのはここからです。

### `$derived` は同じオブジェクトだと値が変化したと思ってくれない

先ほどのストアを使ったApp.svelteに `$derived` を使った変数を追加して、そちらをPostコンポーネントに渡すようにします。

```svelte:App.svelte
<script>
  ... //（省略）

  let post = $derived($postStore);
</script>

<Post {post}/>
<button onclick={updateUserName}>updateUserName</button>
<button onclick={updateUser}>updateUser</button>
```

これだけで、`updateUserName` を実行してもユーザー名が再描画されなくなってしまいました。
`$` でストアの値を参照しているところは値が変化したとみなしても、 `post` 自身は値が変化したとみなされないためです。
そして、ストアに持たせたuserやそのnameといったプロパティは "deeply reactive" ではないので、Userコンポーネントでは再描画がされません。

### `{#each}` との組み合わせ

さらにハマった例を紹介します。

以下のコードでは、ストア内に配列が2重にあって、 2重の `{#each}` でそれを表示しています。
`update` で先頭グループの2人目のユーザー（Bob）の名前の後ろに "+" を付けているのですが、表示が更新されません。

なお、先頭に付けた `<svelte:options runes />` を消すと、このコンポーネントではRuneを一切使っていないのでSvelte 4のコンポーネントとして動くのですが、そうすると表示は更新されるようになります。

```svelte
<svelte:options runes /> <!-- ← Svelte 5モード。またはどこかで $stateなどを使っても同じ -->
<script>
  import { writable } from "svelte/store";
  const store = writable([
    {
      name: "Group 1",
      users: [
        { name: "Alice" },
        { name: "Bob" },
        { name: "Charlie" },
      ],
    },
    {
      name: "Group 2",
      users: [
        { name: "Dorothy" },
      ],
    },
  ]);

  function update() {
    store.update(groups => {
      groups[0].users[1].name = groups[0].users[1].name + "+";
      return groups;
    })
  }
</script>

{#each $store as group}
  <h2>{group.name}</h2>
  {#each group.users as user}
    <div>{user.name}</div>
  {/each}
{/each}
<button onclick={update}>Update</button>
```

原因ですが、どうやらSvelte 5では `{#each}` に渡した配列は基本的には配列自体が同じオブジェクトであれば値が変化したとはみなさないようです。
ですが、渡した配列が `$` でストアを参照したものであれば、配列自体が同じオブジェクトであっても値が変化したとみなすようです。

ところが、そこで `as` で受け取った変数は、ちょうど先ほどの例の `$derived` を使って作った変数のようになっていて、内側の `{#each}` には `$` のストア参照を渡しているわけではないので、ここは変化があったと思ってくれなくて再描画されないみたいです😢

```svelte
{#each $store as group}       <!-- ここはストア参照 -->
  <h2>{group.name}</h2>       <!-- 同じ配列でも再描画される -->  
  {#each group.users as user}   <!-- ここはストア参照ではない -->
    <div>{user.name}</div>      <!-- 同じ配列なら再描画されない -->
  {/each}
{/each}
```

## まとめ

- Svelte 5では実行時に参照したものが変わったときに再描画・再評価される。
  - トリガーとして含めたくない場合は `untrack` を使う。
- Runeで作った値はストアとは違って直接共有できない。
  - 共有するにはクラスを使うか、値の取得・更新を関数経由で行う。
- ストアを同じオブジェクトを使って更新している処理は注意が必要。
  - `$` で直接ストアを参照したときにのみ値が変わったと判断される。