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

image.png

はじめに

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

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

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

(本手)Svelte での Async

Svelte5(現時点では v5.31.1) での Async は、正直イマイチです。React でいえば、Suspense が使えるようになる前の古き良き(?)時代という感じでしょうか。
ただ、その克服を目指した PR が既に上がっておりまして、かなり期待できそうな気がしております。

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

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

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

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

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

サンプルコード1 の解説

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

  import { useResource } from './resource.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 は現在の検索文字列をUI用に保持するためだけの変数なのでUIによってはなくても構いません。

  const [searchResult, { refetch, abort: abortSearch }] = useResource(
    async (src, { signal }) => { // fetcher
      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 } // options
  );

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()?.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}

  <!-- failed -->
  {#snippet failed(error)}
    <p style="color: red;">Error: {error.message}</p>
    <button onclick={handleAbort}>
      New Search
    </button>
  {/snippet}

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

React と同じように Suspense コンポーネントの、children 部分に検索結果を表示します。検索結果の使い方についてはまあこんな感じかくらいに見ておいてください。条件で分岐してタグを書いていくというのが Svelte 風なのだと思います。React でもこういう書き方ももちろんしますが、どちらかというと、React では Render props を使ったりして工夫する感じの方が多い気がします(後述しますが Svelte でも Render props 風にできます)。どちらがよいかは好みの問題(YMMV)だと思いますが嫌いではないです。
fallback prop には Snippet を渡します。fallback={ほにゃらら} という通常の prop の書き方もできますので、この Snippet というものがいわゆる Render props のようなものなのだと思います。この部分については、このソースのように children と fallback を分けて書いたが方見やすい気がします。

少し脱線ですが、Render Props に関して React 公式が次のように述べています。

Sharing code between components: hooks replaced patterns like render props and higher-order components to allow you to reuse stateful logic without changing your component hierarchy.
React 公式 Blog より抜粋

一応 Render Props が何かを先に述べますが <Component render={arg => ReactElement} /> のように関数形式で描画内容を渡す手法です。fallback={ReactElement} のように RactElement を渡すだけ のものは Render props とはいわないようです(なので上の説明のように Snippet と Render Props を同じとするのは微妙かもしれませんが許してください)。
Render Props は、 ReactElement を返す関数 を props で渡すという、比較的わかりやすく、arg で柔軟な処理を指示できるのが魅力で、その魅力がある限り今後も使い続けられると思いますが、公式はそれを Hooks に置き換えるべきだ、といいたいのだと思います。まず、上記の例のように JSX に埋め込んでしまうと分かりにくいかもしれないですが、それこそ「関数」なので同じ処理をまとめるのがその本来の役割なわけで、公式がこの引用内で言っているSharingreuse のために使われていたケースも多いと思います。「それが目的ならそれは Hooks でもできるから Hooks にしようぜ」が公式の言いたいことのひとつなのだと思います。
また、「Render props だとコンポーネントツリー(component hierarchy)に余計なものが増える(wrapper hell)から Hooks にしようぜ」ともいいたいのだと思います。個人的にはその点はあまり気になりませんが。
以上が、公式が Hooks を推奨する理由です。ただし、Hooks を使う場合は、意識しなければならないことがひとつ増えます。それは レンダーとロジックの分離 です。これはもう React 公式が昔から最重要視している点といっても過言ではありません。
脱線が長くなって申し訳ないのですが、とても大事なところなのでお許しください。
レンダーとロジックの分離 は、 宣言的と命令的を区別せよ とほぼ同じです。少なくとも レンダー === 宣言的 です。
どこかで Hooks を使いつつも Hooks の戻り値として ReactElement を返す手法を薦めていた記事を見た気がするのですが、おそらく公式としてはあまり良い顔をしない形のはずです。なんとなく直感でその手法に違和感を感じた方は天才か正常な React 脳の持ち主です。
宣言的、命令的の区別についてはうまく説明する自信は全くありませんので他で学んでください(^^;
逆に私に分かりやく教えてくれる方を絶賛募集中です。
ヒントというのはおこがましいですが、設計図 =レンダ=宣言的、ブラックボックス =ロジック≒命令的です。我々下僕SEには、ノーコードアプリ=宣言的、コーディング=命令的の方が分かりやすいですか?え、全然わかりやすくない?とにかく React での設計図は ReactElement です。そして Hooks が、ブラックボックス(といってしまうといいすぎなのかもしれませんが・・・むず・・・)です。そもそも本稿とは全然関係ない話なので理解できる必要はないですが・・・

const useMyHook = 引数 => {
  const [state] = useStae(0) // など別の Hook を使う
  return 引数に応じて返す結果
}

const MyComponent = props => {
  const state = useMyHook(引数) // ロジックはこの中に
  return (<div>I like {state ? 'React' : 'Svelte'}!</div>)
}

三項演算子だから宣言的だという意味ではありませんので注意してくださいね。仮に if else でも宣言的です。ちなみに state > 10000 が条件であっても宣言的です。また、仮に state() ? として state が関数型だったとしても宣言的です。仮に state が描画用の文字列だったとしても宣言的ですし、その文字列を一旦別の変数 s に格納してその s に加工を加えて表示したとしても宣言的です。すなわち、state の型や途中の一時的な処理は問題にならないということです。ただ、おそらく、state がただの文字列ではなく ReactElement ならダウト!です。普通の関数が ReactElement を返しそれをそのまま表示するのはよいのですが(この表現が非常に難しい・・・)、Hooks が ReactElement に少しでも接触するとおそらくダウト!です。
「うまく区別できそうもない・・・」というキモチはよくわかります!一緒に精進しましょう!悩んだら上級 SE に相談です!それでも分からなかったら「Hooks と ReactElement は接触禁止!それが React のポリシーだ!」と理解すれば十分です。すなわち、すでに信じているはずの pure ルールと同じ立ち位置であるということです。信じる者は救われます!
とはいっても(まだつづくんかい)、この「レンダーとロジックの分離」はコードを奇麗に保つくらいのものですので(多分)、pure ルールのようにそれに反すると不安定になる類のものではないです(多分)。「コードは不潔でよいのです。私は私の信じた途をゆきますので放っておいてください」という方は忘れてください。
ちなみに私は render props 否定派ではありませんのでそれだけは誤解しないでください。render props は絶対に宣言的なものですので(pure ルールを守る限りですが)、その点は楽です。Wrapper Hell の解消をよしとするか、その楽をよしとするか、ということでしかないのかもしれません。公式の態度からは「render props を駆逐してやる!」、のような気迫が感じられますので、いつかは駆逐されるのでしょうが。
なお、HOC についてはずいぶんごぶさたでまったく使用しておりませんのでコメントは差し控えさせていただきます。

レンダーとロジックの分離の話を少しでも理解いただけた方はもうわかっていると思うのですが(もうえええよ、脱線なげぇえよ)、React における「レンダー部分での条件分岐どうするか問題」を、Hooks が解決してくれるわけではありません。「レンダー部分での条件分岐」としては、(1) 即時実行関数(または普通の関数を実行)を使う(2) 三項演算子などを使う(3) SolidJS ライクな Switch, Match コンポーネントを自作する という3つのアプローチが考えられますが、(1) には「即時関数って見ずらい」という Cons、(2) には ネストすると見ずらい という Cons、(3)には「Wrapper Hell になりやすい」という Cons があります。個人的には (1) が★3、(2)が★2、(3)が★1ですが、React 公式はこの点についてはユーザー任せの態度のようです。そういう意味で Svelte の #if :else を使ってください、という態度は個人的には好きです。#match とかあったらなおいっそう好きになれそうです。

脱線しすぎました。話を Snippet に戻します。エラー時の表示は、諸事情(下記)により <svelte:boundary> には対応不可能っぽいので Suspense に failed prop を追加しています。

Errors occurring outside the rendering process (for example, in event handlers or after a setTimeout or async work) are not caught by error boundaries.
svelte:boundary

useResource の解説

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

export const globalGroup = Symbol();

const groupStates = [];
export function useGroupState(group) {
  if (!groupStates[group]) {
    const init = $state({amount: 0, pending: 0, error: undefined});
    groupStates[group] = init;
  }
  return groupStates[group];
}

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(以下 rss) をグループ化して処理すためのものです。React や Svelte のように、Suspens の子供が利用している rss を自動的にグループ化できればよいのですが、私の力量ではとてもできそうにもないので、group prop でグループ化できるようにしてみました。ただ、あまりテストしていませんで不具合があればお知らせください。
この辺も今回はこれ以上解説しません。

  let value = $state(undefined);
  let state = $state('unresolved');

value, state をリアクティブにして、コンポーネントの更新を起動するようにします。
なお、このように $state を直接使うのもよいですが、過去の記事で作成したcreateSignal を使うのもなかなかよいものですよ。正直 $state() は、あれしちゃだめ、これしちゃだめ、という制約が割と多くて投げ出したくなることがしばしばあります。createSignal を使えば、制約を受けずに サクサクかけますよ。このサンプルではあえて Svelte のお作法に則って書くように努めておりますが、SolidJS歴やVanillaJS歴の長い方は Signals 方式の方が使いやすいのではないかと(YMMV)。createSignaluseSignal に改名して signals.svelte.js に容れておきましたのでよろしければご利用ください。

  async function load(src, signal) {

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

  function abort() {
    if (abortController) {
      abortController.abort();
      abortController = null;
    }
    groupState.error = undefined; 
    value = undefined;
    state = 'unresolved';
  }

中止処理です。状態をリセットする機能も有します。

  async function refetch(src) {
    abort();

    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 の実行を手動で起動する処理です。
src が指定された場合はそれが新しい source になります。逆に src 引数が省略された場合は、既存の source が使用されます。

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

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

初回起動時に、initialStartが真なら自動的に refetch されます。また、source にリアクティブな変数の getter が指定され、それが変更された場合にも自動的に refetch されます。
この、リアクティブ変数というものが、便利である一方できちんと理解しないとなかなか使いこなせないものといえます。よく、「React は難しい」という評価を見聞きすることがありますが、リアクティブという概念も易しそうに見えて意外に奥は深いです。考えてみれば、描画処理ってそもそもそんな簡単な処理ではないじゃないですか。安易に楽をしようとせずしっかり勉強しましょう。
なお、$effect は、既述のように React の useEffect のようなものですが、依存するリアクティブ変数を自動的に検知しますので、bag.source() という一見無意味なコードを書いて依存することを意図的に伝えております(他によいコードがあれば教えてください)。ちなみに、2つの if 文を入れ替えると自動検知がうまく働きませんでした。仕様なのかバグなのかは調べておりませんが、仕様であるとしたらやや分かりにくい仕様といえそうです。
untrack() は逆に自動検知を行わないようにする機能です。refetch()内で使用しているリアクティブな変数までここで検知されると、永久ループのおそれがあるためです。
このように、$effect での依存関係の自動検知にはよいところもある反面、却って複雑化する側面もありますので、React の useEffect のように手動で依存関係を指定できるのも悪くないなと感じました。

  $effect(() => {
    return () => {
      if (abortController) {
        abortController.abort();
      }
      groupState.amount--;
      if (groupState.amount <= 0) {
        delete groupStates[bag.options.group]; 
      }
    };
  });

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

  const rss = () => value;
  rss.error = () => groupState.error;
  rss.state = () => state;

rss を getter 関数にしています。したがって value の中身を得る場合は rss() と関数呼び出しを行うことになります。SolidJS の createResource と同じ仕様です。理解できなくてもよいですが、このような仕様にすることで Vanilla JavaScript からも useResource を使えるようになります。もちろん、class にして property getter, setter を使うなどいろいろ考えられるところですが、ねっこは同じことで、あとは好みの問題です。私は zig lang がそうであるように「関数なのにただの変数ぽく見えてしまうのはわかりにくいと考える派」です。
error, state は関数のメンバ変数ということになります。JavaScript では、関数も Object の一種ですのでこのようにメンバ変数を加えることができます。そして、これらも getter 関数にしていますので、利用する場合はやはり rss.error(), rss.state() のようにします。
このように、関数呼び出しを多用することで Svelte はリアクティブな機能を実現しています(多分)。その点は、SolidJS も同様であり(多分)、仮想 DOM を使わない手法では一般的なやり方なのかもしれません。

Suspense の解説

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

<script>
  // Suspense.svelte
  import { globalGroup, useGroupState } from './resource.svelte';

  let { group = globalGroup, fallback, failed, children } = $props(); 
  const groupState = useGroupState(group);
</script>

{#if fallback && groupState.pending > 0}
  {@render fallback()}
{:else if failed && groupState.error} 
  {@render failed(groupState.error)}
{:else}
  {@render children?.()}
{/if}

これだけです。難しい部分は group 関係だけだと思います。
pending 中は fallback snippet を描画します。
error が発生したら failed snippet を描画します。
通常は children を描画します。
以上で、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 を直接参照して分岐するため、ネストが深くなりみにくくなりがちですが、snippet 型がよいか、このように条件分岐型がよいかは好みの問題であると思いますのでお好きな方を使いましょう(YMMV)。
また、Suspense のように group 化をしたい場合は自分なりのそういうコードを追加する必要があります。

最後に

今回はここまでにします。次回は 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?