3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SvelteAdvent Calendar 2024

Day 25

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

Last updated at Posted at 2024-12-24

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

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

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

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

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

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

// 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を使って書き直してみます。

// 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 は参照せずに終わります。

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

ここで isCounter1false に変わったとします。

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

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

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

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

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

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

$: total = yPlusAValue(x);

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

// 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では実行時にトリガーが収集されるため、次の再計算のトリガーは xy の両方になります。

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

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

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

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

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

import { untrack } from "svelte";

...

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

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

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で作ったリアクティブな値に置き換えられるものもあります。

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

ストアはオブジェクト

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

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

another.set(2);

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

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

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

another = 2;

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

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

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

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

another.value = 2;

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

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

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

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コンパイラはここには使われません。
それでも、LogicisEven を呼び出した結果が、リアクティブに再描画されます。
isEven の呼び出しにより、NumberValuevalue のゲッターが呼ばれるため、リアクティブな値のトリガーとして収集されるためです)

NumberValue.svelte.js
export class NumberValue {
  value = $state(0);
}
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;
  }
}
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コンパイラを通せばいいようです。

クラスを使わない場合

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

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

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 で updateUserNameupdateUser のどちらを実行しても、postやuserを見ているPostコンポーネントでの再評価は発生せず、コンソールのログは増えません。
ですが、nameを見ているUserコンポーネントは updateUserName によるnameの変化を検知して再描画されます。

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>
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>
User.svelte
<script>
  let { user } = $props();
</script>

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

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

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

先ほどの例のApp.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コンポーネントに渡すようにします。

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: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} には $ のストア参照を渡しているわけではないので、ここは変化があったと思ってくれなくて再描画されないみたいです😢

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

まとめ

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?