0
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?

Svelte でコンポーネントを作り React で糊付けする(Async - Suspense 編)

Last updated at Posted at 2025-05-20

はじめに

前々々々回前々々回前々回前回 と、順調に自己満足してきましたが、そろそろ終焉が近づいてきました。安心してください。

さて、ただのバッチ処理的なスクリプトファイル程度しか作成しないエンジニアならいざ知らず、WEB アプリを作成するエンジニアたるもの Async(非同期)の理解が欠かせません。Async を理解していないと給料は絶対に上がりません。断言できます。

React の Async はよくできていますので、本シリーズ的には、Async は React 側で処理をして、Svelte では Sync のみ、という手も考えられるところですが、せっかくなので Svelte で、React や SolidJS のように Async できないか実験してみましょう!

(本手)Svelte での Async

Svelte5(現時点では v5.31.1) での Async は、正直イマイチです。
ただ、PR が既に上がっておりまして、かなり期待できそうな気がしております。

(疑問手)React, SolidJS みたいな Async

上記 Discussion 内で、React や SolidJS のようなやり方は否定されていますので何ですが、本手がマージされるのはまだ先でしょうし、お勉強のために、React や SolidJS のような Async を自作してみました。

なお、useResource, useTransition, Suspense の細かい仕様の解説については、今後要望が多ければ(かつ私が絶好調であれば)やりたいと思います。

サンプルコード1 - Suspense 的なもの

まずは、Suspense 的なものを利用したサンプルです。

Svelte Play Ground - サンプルコード1

サンプルコード1 の解説

さっそく App.svelte にダイブしましょう。

  import { useResource } from './signals.svelte';
  import Suspense from './Suspense.svelte';
  import {wait} from './utils.js';

useResource は、 SolidJS の createResource 的な Hook? です。Hook と呼んでよいのかどうかはよくわかっておりませんが、ナウいのかなと思いとりあえずこの名前にしました。

Suspense は、React や SolidJS の Suspense 的なものです。

wait はよくある、デバッグ用のウェイト処理です。AbortController を使って中断できるようになっています。
wait 内では、AbortController 経由で中断された場合に (new DOMException('Wait aborted', 'AbortError') として例外を投げています。どうもこれで fetch を中断した場合と同じ処理になるようなのでそうしています。
もし今後、Promise の中断処理を実装するときは、これを参考にしてください。そうすることで Promise の中断を統一して判断できるようになります。なお、./utils.js 内の説明はこれ以上しません。

  let inputValue = '', queryValue = '';

inputValueinput の現在の入力値を保持するための変数です。React 脳なので果たしてこういうやり方でよいのか自信ありません。識者の方のコメントをお待ちしております。queryValue は現在の検索文字列を保持するための変数です。

  const [searchResult, { refetch, abort: abortSearch }] = useResource(
    async (src, { signal }) => {
      await wait(3000, signal);
      const response = await fetch(`https://dummyjson.com/products/search?q=${src}`, { signal });
      if (!response.ok) throw new Error(`dummyjson API error: ${response.status}`);
      const data = await response.json();
      return data.products;
    },
	{ initialStart: false }
  );

useResource を使用して、リアクティブな検索結果を作成します。
引数は SolidJS の createResource と同じ感じにしましたので、1番目の引数に fetcher を渡すパターンと、1番目の引数に source、2 番目の引数に fetcher を渡すパターンと2種類あります。
options に initialStart: false を指定すると fetcher を自動実行せずに待機します。
fetcher の部分は JavaScript で Async の経験がある方であれば特に難しいところはないかと思います。

  function handleSearch() {
	queryValue = inputValue;
    refetch(queryValue);
  }

検索を実行するハンドラです。useResource が返してきた関数を呼んでいるだけです。
なお、queryValue は表示専用ですので必須ではありません。

  function handleAbort() {
    abortSearch();
  }

検索を中止するハンドラです。useResource が返してきた関数を呼んでいるだけです。

<Suspense>
  <!-- children -->
  <input bind:value={inputValue} placeholder="Enter product keyword" />

  <button onclick={handleSearch} disabled={!inputValue}>
    Search
  </button>

  {#if queryValue}
    {#if searchResult.error()}
      <p style="color: red;">Error: {searchResult.error()?.message}</p>
    {:else if searchResult()?.length}
      <p>=== Search results for "{queryValue}" ===</p>
      <ul>
        {#each searchResult() as product}
          <li>
            {product.title} ({product.price} $)
          </li>
        {/each}
      </ul>
    {:else}
      <p>No products found for "{queryValue}".</p>
    {/if}
  {/if}

  <!-- fallback -->
  {#snippet fallback()}
    <button onclick={handleAbort}>
      Abort Search
    </button>
    <p>Loading search results...</p>
  {/snippet}
</Suspense>

React と同じように Suspense コンポーネントの、children 部分に検索結果を表示します。検索結果の使い方についてはまあこんな感じかくらいに見ておいてください。エラー時の表示は <svelte:boundary> に任せたほうが良いかもしれません。
fallback prop には Snippet を渡します。fallback={ほにゃらら} という通常の prop の書き方もできますが、このソースのように分けても書けるらしいです。分けて書いた方が見やすい気がします。

useResource の解説

signals.svelte.js 内の useResource の解説をします。

export const resourceStates = [];
export const globalGroup = Symbol();

Promise の完了を待機するための変数です。この辺は今回はあまり詳しく説明しません。

export function useResource(arg1, arg2, arg3) {

useResource にダイブしましょう。

  const bag = {};

  if (typeof arg2 === 'function') {
    // source, fetcher, (options)
    bag.source = arg1;
    bag.fetcher = arg2;
    bag.options = arg3 || {};
  } else {
    // fetcher, (options)
    bag.fetcher = arg1;
    bag.options = arg2 || {};
    bag.source = undefined;
  }

引数を bag に詰め込んでいます。
引数の順序で処理を変えるためやや複雑になっていますが、その辺は本筋ではありませんの理解できなくて OK です。

  if (bag.options.initialStart === undefined) {
    bag.options.initialStart = true;		
  }

  if (!bag.options.group) {
    bag.options.group = globalGroup;		
  }

options.initialStrat が未指定の場合は true にして fetcher を自動実行します。

group は、resource をグループ化して処理すためのものです。React や Svelte のように、Suspens の子供をグループ化できればよいのですが私の力量ではできそうにもないので、group prop でグループ化できるようにしてみました。ただ、あまりテストしていませんで注意です。
この辺も今回はこれ以上解説しません。

	const [value, setValue] = useSignal(undefined);
	const [state, setState] = useSignal('unresolved');
	const [error, setError] = useSignal(undefined);

value, state, error をリアクティブにして、コンポーネントの更新を起動するようにします。
なお、useSignal は、すでにやった createSignal と同じものです。useXXX の方がナウいのかなと思いましたので変更しました。

  async function load(signal) {

Promise を実行する処理です。今回は立ち入りません。

  function abort() {
    if (abortController) {
      abortController.abort();
      abortController = null;
    }
  }

中止処理です。

  async function refetch(src) {
    abort();

    if (resourceStates[bag.options.group]?.amount <= 0 && state() !== 'pending') {
      return; 
    }

    if (bag.options.initialStart !== true) {
      bag.options.initialStart = true;
      return; 
    }

    if (src !== undefined) {
      bag.source = src; 
    }

    const resolvedSource = typeof bag.source === 'function' ? bag.source() : bag.source;

    abortController = new AbortController(); 
    await load(resolvedSource, abortController.signal);
  }

Promise の実行を手動で起動する処理です。
if (resourceStates[bag.options.group]?.amount <= 0 && state() !== 'pending') { は必要かどうか疑わしいですが、Gemini 上級SE氏が付けろというので付けました。
initialStart が false の場合は何もしません。
src が指定された場合はそれが新しい source になります。

  $effect(() => {
    if (typeof bag.source === 'function') {
      // Allow opt-in explicit dependency tracking for $effect
      bag.source();
    }
    untrack(() => refetch());
  });

初回起動時及び source がリアクティブな変数だった場合に自動的に refetch します。
source() とすることで source に依存する effect であることを伝えていますが、もっといい方法はないものでしょうか。

  $effect(() => {
    return () => {
      if (abortController) {
        abortController.abort();
      }
      resourceStates[bag.options.group].amount--;
    };
  });

後始末をしています。ReactuseEffect に近いですが、useEffect で手動で指定する依存関係(2番目の引数)を自動的に感知してくれます。そのことを理解していないと Svelte5 でも onMount を使いたくなってしまいますので注意してください。今回の例でいえば、この $effect 内ではリアクティブな変数を使用していませんので(というか何も処理がない)、 onMount と同じくマウント後に一度呼ばれるだけで、再描画時には呼ばれません(といっても何も処理がないので仮に呼ばれても問題ないですが)。そして戻り値の関数が useEffect と同じくクリーンアップ処理になります。

	const rss = value;
	rss.error = error;
	rss.state = state;

rss に useSignal の getter 関数である value を代入していますので rss は関数になります。
したがって value の中身を得る場合は rss() と関数呼び出しを行うことになります。
error, state は関数のメンバ変数ということになります。JavaScript では、関数も Object の一種ですのでこのようにメンバ変数を加えることができます。そして、これらにも useSignal の getter 関数を代入していますので、利用する場合はやはり rss.error(), rss.state() のようにします。このように、関数呼び出しを多用することで Svelte はリアクティブな機能を実現しています(多分)。その点は、SolidJS も同様であり(多分)、仮想 DOM を使わない手法では一般的なやり方なのかもしれません。

Suspense の解説

Suspense.svelte を見てみましょう。

<script>
  import { pendingPromisesSignal, resourceStates, globalGroup } from './signals.svelte';

  let { fallback, children, group = globalGroup } = $props();

  const pending = resourceStates[group].signal[0];
	
  const isPending = $derived(pending() > 0);
</script>

{#if isPending}
  {@render fallback?.()}
{:else}
  {@render children?.()}
{/if}

これだけです。難しい部分は group 関係だけだと思いますので解説はしません。
以上で、Suspense を使用したサンプル1 の解説は終わりです。
続けて Suspense を利用しないサンプル0も見て、Suspense の良さを伝えたいと思います。

サンプル0の解説

useResource は、Suspense がなくても使えます。

Svelte Play Ground - サンプルコード0

サンプル1 とサンプル0 の違いはテンプレートでの条件分岐だけです。

{#if searchResult.state() === 'pending'}
  <button onclick={handleAbort}>
    Abort Search
  </button>
  <p>Loading search results...</p>
{:else}
  <input bind:value={inputValue} placeholder="Enter product keyword" />

  <button onclick={handleSearch} disabled={!inputValue}>
    Search
  </button>

  {#if queryValue}
    {#if searchResult.error()}
      <p style="color: red;">Error: {searchResult.error().message}</p>
    {:else if searchResult()?.length}
	  <p>=== Search results for "{queryValue}" ===</p>
      <ul>
        {#each searchResult() as product}
          <li>
            {product.title} ({product.price} $)
          </li>
        {/each}
      </ul>
    {:else}
      <p>No products found for "{queryValue}".</p>
    {/if}
  {/if}
{/if}

state を直接参照して分岐するため、みにくくなりがちです。

最後に

今回はここまでにします。次回は useTransition の解説です。楽しみにせずにお待ちください。
こんなものを使う人はいないとは思いますが、気に入った方はご利用ください。ライセンスはいわゆる MIT ライセンスです。

Let’s have fun with React and Svelte!

0
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
0
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?