mcpField
A
async-preact-signals

async-preact-signals

github.com/rodydavis開発6060 回インストール2 週間前に更新
リポジトリ

async-preact-signals について

Preact Signals を使う開発者向けに、Promise による非同期データを扱うパターンを示すスキルです。asyncSignal と状態クラスで読み込み・データ・エラーを表し、前のリクエストの中止や状態の変換にも対応します。

主な機能

  1. 01状態クラスによる読み込み・データ・エラーの表現
  2. 02asyncSignal による非同期シグナルの作成
  3. 03依存値の変更時に前のリクエストを中止
  4. 04状態を別の値へ変換

活用シーン

  1. 01Signals の UI でリクエスト状態を表示
  2. 02依存値の変更に応じて非同期データを更新
  3. 03複数リクエストで読み込みとエラー処理を再利用

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/rodydavis/skills --skill async-preact-signals

インストール後、Claude Code で/async-preact-signalsを入力して読み込まれたか確認します。

詳細

Async Preact Signals

Javascript でシグナルを扱う場合、Promiseからの非同期データを扱うことはよくあります。

非同期と同期

しかし、ほかの状態管理ライブラリとは異なり、シグナルには_非同期_の状態グラフがなく、すべての値を_同期的に_計算する必要があります。

シグナルを使い始めたばかりの人は、関数のコールバックにasyncを追加するだけでよいと考えがちですが、これは内部の仕組みを壊し、動作がundefinedになります。☹️

非同期関数は漏れのある抽象化であり、グラフ全体にわたって非同期処理を扱う必要があります。また、非同期が常に優れているとは限らず、パフォーマンスに影響する場合もあります。😬

Promise の扱い方

同期処理でできることはまだたくさんありますし、よく使われる非同期パターンも簡単に扱えるようにできます。

たとえば、fetchを使ってhttpリクエストを行う場合、Promiseからデータを返し、UI を更新したいとします。

const el = document.querySelector('#output');
let postId = '123';
fetch(`/posts/${postId}`).then(res => res.json()).then(post => {
    el.innerText = post.title;
})

シグナルを追加すると、投稿 ID が変わるたびに fetch を再実行できるようになります。

import { effect, signal } from "@preact/signals-core";

const el = document.querySelector('#output');
const postId =  signal( '123');

effect(() => {
    fetch(`/posts/${postId.value}`).then(res => res.json()).then(post => {
        el.innerText = post.title;
    });
});

これは改善されていますが、投稿 ID が前の fetch の完了前に変わった場合、前のリクエストを停止する処理が必要です。

import { effect, signal } from "@preact/signals-core";

const el = document.querySelector('#output');
const postId =  signal( '123');
let controller;

effect(() => {
    if (controller) {
         controller.abort();
    }
    controller = new AbortController();
    const signal = controller.signal;
    try {
       fetch(`/posts/${postId.value}`, { signal }).then(res => res.json()).then(post => {
            el.innerText = post.title;
        }); 
    } catch (err) {
       // todo: show error message
    }
});

しかし、これではローディング状態やエラー状態など、通常表示したい多くの情報がまだ省略されています。

import { effect, signal, batch } from "@preact/signals-core";

const el = document.querySelector('#output');
const postId =  signal( '123');
const postData = signal({});
const errorMessage = signal('');
const loading = signal(false);
let controller;

effect(() => {
    if (controller) {
         controller.abort();
    }
    controller = new AbortController();
    const signal = controller.signal;
    batch(() => {
       loading.value = true;
       errorMessage.value = '';
       postData.value = {};
    });
    try {
       fetch(`/posts/${postId.value}`, { signal }).then(res => res.json()).then(post => {
            batch(() => {
                 postData.value = post;
                 loading.value = false;
             });
        }); 
    } catch (err) {
        errorMessage.value = err.message;
    }
});
effect(() =>  {
    if (loading.value) {
        el.innerText = 'Loading...';
    } else if (errorMessage.value) {
        el.innerText = `Error: ${errorMessage.value}`;
    } else {
        el.innerText = postData.value.title;
    }
});

これで適切な状態を表示できますが、これは 1 つのリクエストにしか対応していません……

クラスでまとめて再利用するか、非同期データを扱える新しい種類のシグナルを作成できます。

AsyncState

ローディング状態を簡単に拡張できる基底クラスを用意します。

export class AsyncState<T> {
  constructor() {}

  get value(): T | null {
    return null;
  }

  get requireValue(): T {
    throw new Error("Value not set");
  }

  get error(): any {
    return null;
  }

  get isLoading(): boolean {
    return false;
  }

  get hasValue(): boolean {
    return false;
  }

  get hasError(): boolean {
    return false;
  }

  map<R>(builders: {
    onLoading: () => R;
    onError: (error: any) => R;
    onData: (data: T) => R;
  }): R {
    if (this.hasError) {
      return builders.onError(this.error);
    }
    if (this.hasValue) {
      return builders.onData(this.requireValue);
    }
    return builders.onLoading();
  }
}

このクラスは、私が作成したpreact signals の Dart 移植版に由来します。

これにより、実際の値やエラーがあるか、またはローディング中かを簡単に確認できます。また、状態を別の値にマッピングするための便利なビルダーメソッドも用意されています。🤩

AsyncData

ローディング状態はAsyncStateを拡張し、コンストラクターに渡された値をオーバーライドされたメソッドに渡します。

export class AsyncData<T> extends AsyncState<T> {
  private _value: T;

  constructor(value: T) {
    super();
    this._value = value;
  }

  get requireValue(): T {
    return this._value;
  }

  get hasValue(): boolean {
    return true;
  }

  toString() {
    return `AsyncData{${this._value}}`;
  }
}
AsyncLoading

ローディング状態では、AsyncDataと同様にメソッドをオーバーライドします。

export class AsyncLoading<T> extends AsyncState<T> {
  get value(): T | null {
    return null;
  }

  get isLoading(): boolean {
    return true;
  }

  toString() {
    return `AsyncLoading{}`;
  }
}
AsyncError

エラー状態では、例外をスローするのではなく(Go のように)、任意の型のオブジェクトを渡してエラーを値として返せます。

export class AsyncError<T> extends AsyncState<T> {
  private _error: any;

  constructor(error: any) {
    super();
    this._error = error;
  }

  get error(): any {
    return this._error;
  }

  get hasError(): boolean {
    return true;
  }

  toString() {
    return `AsyncError{${this._error}}`;
  }
}

asyncSignal

これで状態クラスが作成できたので、先ほど説明したロジックをすべて備えた非同期シグナルを作成する関数を作れます。

いつでも同期値を表示でき、前のリクエストを中止する方法も必要です。

export function asyncSignal<T>(
  cb: () => Promise<T>
): ReadonlySignal<AsyncState<T>> {
  const loading = new AsyncLoading<T>();
  const reset = Symbol("reset");
  const s = signal<AsyncState<T>>(loading);
  const c = computed<Promise<T>>(cb);
  let controller: AbortController | null;
  let abortSignal: AbortSignal | null;

  function execute(cb: Promise<T>, cancel: AbortSignal) {
    (async () => {
      s.value = loading;
      try {
        const result = await new Promise<T>(async (resolve, reject) => {
          if (cancel.aborted) {
            reject(cancel.reason);
          }
          cancel.addEventListener("abort", () => {
            reject(cancel.reason);
          });
          try {
            const result = await cb;
            if (cancel.aborted) {
              reject(cancel.reason);
              return;
            }
            resolve(result);
          } catch (error) {
            reject(error);
          }
        });
        s.value = new AsyncData<T>(result);
      } catch (error) {
        if (error === reset) {
          s.value = loading;
        } else {
          s.value = new AsyncError<T>(error);
        }
      }
    })();
  }

  effect(() => {
    if (controller != null) {
      controller.abort(reset);
    }
    controller = new AbortController();
    abortSignal = controller.signal;
    execute(c.value, abortSignal);
  });

  return s;
}

これにより、複数の非同期シグナルを簡単に作成でき、エフェクトやコンピューテッドなど、アプリケーション内のシグナルを使うほかの場所でも利用できます。

const el = document.querySelector('#output');
const postId =  signal('123');

const result = asyncSignal(() => fetch(`/posts/${postId.value}`).then(res => res.json()));

effect(() => {
   el.innerText = result.value.map({
      onLoading: () => 'Loading...',
      onError: (err) => `Error: ${err}`,
      onData: (post) => post.title, 
   });
});

postId.value = '456';

まとめ

Preact Signals の GitHub ディスカッションをこちらで始めました。また、こちらで最終的なソースコードの Gist を確認できます。🎉

これにより、非同期データをとても扱いやすくなりました。改善方法について、ぜひご意見をお聞かせください。👀

また、Angular の非同期シグナルの仕組みに興味があれば、resource signalとcomputedFrom/Async signalをご覧ください。

コメント