リソース (Resources)
resource は、PreffX における非同期データ処理のためのプリミティブです。これはフェッチャー(非同期関数)をラップし、リアクティブな状態(state、error、pending)に加えて、フェッチのライフサイクルを制御するためのハンドラ群(refetch、mutate、cancel、invalidate)を提供します。
リソースは**アイソモーフィック(isomorphic)**です。つまり、サーバー上(SSRプリロード)とクライアント上(ハイドレーションおよびリアクティビティ)の双方で、コードを分岐させることなく、同一のコンポーネントコードを実行できます。
基本的な使用方法
import type { PC } from 'preffx';
export const UserCard: PC = (_props, { resource, computed }) => {
const [user] = resource(async () => {
const res = await fetch('/api/user');
return res.json();
});
const display = computed(() => user.state.value?.name ?? 'loading');
return (
<article className="user">
<h1>{display}</h1>
</article>
);
};resource(fetcher) はタプルを返します。
const [res, handlers] = resource(fetcher, options?);状態 (State)
最初の要素である res は、以下の3つのシグナルを持つリアクティブなスナップショットです。
| フィールド | 型 | 説明 |
|---|---|---|
state | ReadonlySignal<T | null> | 取得されたデータ。読み込み中やエラー発生後は null となる |
pending | ReadonlySignal<boolean> | データ取得処理の実行中は true となる |
error | ReadonlySignal<E | null> | 直近のエラー。エラーがない場合は null となる |
state と error は意図的に分離されています。エラーはデータ状態の一部ではありません。これにより、これら2つの関心事が独立した状態に保たれます。
const [res] = resource<string, ApiError>(fetchUser);
const view = computed(() => {
if (res.pending.value) return 'loading…';
if (res.error.value) return `Error: ${res.error.value.message}`;
return res.state.value;
});エラー型 E は、フェッチャー(fetcher)の拒否(rejection)型から推論されますが、明示的に指定することも可能です(例: resource<string, ApiError>(fetcher))。
ハンドラ
2番目の要素は、制御用オブジェクトです。
| ハンドラ | 説明 |
|---|---|
refetch() | フェッチャーを再実行します(実行中のリクエストがある場合は、それを中断してから実行します)。 |
mutate(v) | フェッチャーを再実行することなく、解決値(resolved value)を楽観的(optimistic)に設定します。 |
cancel() | 実行中のリクエストを中断し、pending 状態をリセットします。キャッシュには影響を与えません。 |
invalidate() | このリソースの key に対応するキャッシュエントリを無効化します(明示的なキーが指定されている場合のみ)。 |
const [user, { refetch, mutate, cancel }] = resource(fetchUser);
return (
<div>
<button onClick={refetch}>Refresh</button>
<button onClick={() => mutate({ name: 'Optimistic' })}>Set</button>
<button onClick={cancel}>Cancel</button>
</div>
);オプション
resource(fetcher, options?) は以下のオプションを受け取ります。
| オプション | 型 | 説明 |
|---|---|---|
initial | T | 初期データ。初回レンダリング時に pending が false のままとなり、Suspense のフォールバックは表示されません。 |
timeout | number | 指定したミリ秒(ms)経過後にフェッチを中断し、リソースの状態を「失敗」とします。 |
key | string | キャッシュや、複数のコンシューマー間での共有に使用する一意の ID。 |
keepPreviousState | boolean | 再フェッチや依存関係の再実行が行われている間、直前の state を保持します。 |
initial
initial を指定すると、データがすでに利用可能な状態となるため、pending は最初から false となり、Suspense のフォールバックは表示されません。
const [user] = resource(fetchUser, { initial: cachedUser });timeout
const [user] = resource(fetchUser, { timeout: 5000 });タイムアウトが発生すると、実行中のリクエストは中断され、error に「Resource timed out(リソースのタイムアウト)」エラーが設定されます。
key とキャッシュ
明示的なキーを指定することで、ルートごとにキャッシュを有効にし、複数のコンシューマー間で共有できるようになります。同じ key を使用する2つのコンポーネントは、単一の state / error エントリを共有し、データ取得は1回のみ行われます。
const [a] = resource(fetchUser, { key: 'user' });
const [b] = resource(fetchUser, { key: 'user' }); // shares the cache entryinvalidate() を呼び出すと該当するエントリーが削除されるため、次に利用する側がデータを再取得することになります。(key を指定しない)位置指定のキーは、キャッシュの対象にはなりません。
keepPreviousState
const [user, { refetch }] = resource(fetchUser, { keepPreviousState: true });再取得(refetch)の実行中、以前の state はそのまま表示され続け(stale-while-revalidate)、error や pending の状態のみが更新されます。
Abort シグナル
フェッチャー(fetcher)には、常に第1引数として AbortSignal が渡されます。
resource(async (signal) => {
const res = await fetch('/api/data', { signal });
return res.json();
});サーバーとクライアントで、以下の挙動は共通しています。
- クライアント — 実行ごとに実際の
AbortControllerが生成されます。refetch()、cancel()、タイムアウト、あるいはアンマウントが発生すると、このAbortControllerは中断(abort)されます。シグナルが中断された場合、データもエラーも適用されません。 - サーバー (SSR) —
preload()中、リソースごとに実際のAbortControllerが生成されます。シグナルが中断された場合、データもエラーも適用されません。これはクライアント側の挙動と一致しています。
注: シグナルを中断(abort)するとリクエストはキャンセルされますが、これはエラーにはなりません。失敗(例:「権限がない」など)を通知したい場合は、代わりにフェッチャー(fetcher)からエラーをスローしてください。
resource(async () => {
if (!hasPermission()) throw new ForbiddenError('no access');
return fetchData();
});依存関係に基づく再取得
フェッチャー(fetcher)がその内部でシグナルを読み取っている場合、そのシグナルが変化すると、リソースは自動的に再実行されます。
const userId = signal(1);
const [user] = resource(async () => {
return fetchUser(userId.value); // re-runs when userId changes
});サーバーサイドレンダリング (SSR)
サーバー上では、resource はエフェクトを実行するのではなく、preload() 用のフェッチャーを登録します。同じコンポーネントを、何も変更することなくそのまま使用できます。
import { createRoot } from 'preffx/server';
const server = createRoot();
const { preload, serialize } = server.renderToString(App, {});
await preload(); // resolve every registered resource
const html = serialize(); // markup + preload data scriptpreload()の実行前は、state.valueはnullであり、pendingはtrueです。preload()の実行後、解決された値がマークアップに埋め込まれ、プリロードデータスクリプトに書き込まれます。- クライアント側でのハイドレーション時に、リソースはプリロードされた値を読み取り、フェッチ処理をスキップします。
完全な例
import type { PC } from 'preffx';
type User = { id: number; name: string };
const UserCard: PC = (_props, { resource, computed }) => {
const [user, { refetch }] = resource<User>(
async (signal) => {
const res = await fetch('/api/user', { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
{ timeout: 5000 }
);
const view = computed(() => {
if (user.pending.value) return 'Loading…';
if (user.error.value) return `Error: ${user.error.value.message}`;
return user.state.value?.name;
});
return (
<div>
<p>{view}</p>
<button onClick={refetch}>Refresh</button>
</div>
);
};