🎱

【fate】Async React 時代のフレームワークを試してみた

に公開

はじめに

最近、React向けのデータクライアントである fateの1.0が本番環境で利用可能になったので、実際に触ってみました。

https://fate.technology/

実際に小さな投稿アプリを作ってみて、一番体験が良かったのは リクエストではなく、Viewを中心にデータ取得を考える ところでした。

一般的なReactアプリでは、データ取得の場所、loading/errorの扱い、取得したデータの受け渡しを考えることが多いです。
fateでは、まず「このコンポーネントはどのViewを必要としているか」から考えます。

この記事では、fateのコンセプトと仕組みを整理しつつ、実際に作ったサンプルで良かったところ、現時点で少し気になったところを書いていこうと思います。

以下が、今回作成したサンプルのソースとなります。

https://github.com/sc30gsw/fate-hono-turso-sample

fateとは

fateは、React向けのデータクライアントです。

公式のCore Conceptsでは、fateの中心にある考え方として Thinking in Views が説明されています。

https://fate.technology/guide/core-concepts

Reactアプリでは、コンポーネントとhooksを中心に考えることが多いです。
fateはそこに、第三のプリミティブとして views を追加します。

Viewは、コンポーネントが必要とするデータを宣言するものです。

export const PostListItemView = view<Post>()({
  id: true,
  title: true,
  createdAt: true,
  author: UserView,
  commentCount: true,
  likeCount: true,
});

そして、root付近で useRequest に渡します。

const { posts } = useRequest({
  posts: { list: PostListItemView },
});

ここでfateがやってくれることは、単なるfetchではありません。

  • コンポーネントが必要なViewを合成する
  • 画面に必要なデータを1つのrequestとして取得する
  • 取得した PostComment をEntity単位で正規化してキャッシュする(詳しくは後述します
  • useView で、そのコンポーネントが宣言したViewの範囲だけを読む
  • loadingはSuspense、errorはError Boundaryに流す

つまり、fateは「APIレスポンスをどう受け取るか」よりも、「コンポーネントがどのデータを必要としていて、それをEntityとしてどう同期するか」を中心にしていると言えます。

ViewはGraphQLでいうところのfragmentに近しいものと言うとイメージが湧きやすいかもしれません。

コンポーネントからViewが合成される

fateの面白いところは、各コンポーネントが必要なViewを持ち、それらがrootに集約されるところです。

公式ドキュメントの図にあるように、以下のイメージです。

fate concept

最終的にViewはroot付近の useRequest に集まり、fateがまとめてデータを取得します。

これにより、子コンポーネントのためだけに親で大きな型を定義したり、propsで大きなバケツリレーをしたりする場面を減らせます。

Entityと正規化キャッシュ

fateは取得したデータをEntity単位で正規化してキャッシュします。

fateのEntityは、アプリで扱う意味のあるデータの単位です。
例えば、PostCommentUser などのデータがEntityです。

正規化キャッシュは、APIレスポンスの形をそのまま保存するのではなく、取得したデータをEntityごとに分解して保存する考え方です。

ざっくり言うと、レスポンス全体を丸ごと保存するのではなく、Post:post-1 のようにIDで引ける辞書としてキャッシュを持ちます。

例えば、投稿詳細のレスポンスに投稿とコメントが含まれている場合、内部では以下のように管理されます。

Post:post-1
  title
  likeCount
  commentCount

Comment:comment-1
  content
  post -> Post:post-1

一覧用View、詳細用View、いいねボタン用Viewが別々でも、同じ投稿であれば内部的には同じ Post:post-1 を見ています。

つまり、fateでは、同じ Post は画面内のどこで読まれても同じEntityとして扱われます。

そのため、いいねmutationで Post.likeCount が変わったときに、一覧用キャッシュ、詳細用キャッシュ、いいねボタン用キャッシュをそれぞれ手で同期する必要がなく、明示的なキャッシュ管理が不要となります。

Entityがわかると、Viewが具体的にどういったものかも見えてきます。

ずばり、Viewとは「そのEntityのどのフィールドを読むか」を表しているものです。
つまり、PostListItemViewPostDetailView が別々でも、同じ Post:post-1 を読んでいるなら、元になるデータは同じ場所にあります。

このおかげで、mutationやoptimistic updateで Post:post-1 が更新されたとき、同じ投稿を読んでいる別のViewにも反映されます。

まとめると、正規化 は、レスポンス全体を丸ごと持つのではなく、Post:post-1 のようにIDで引ける単位へ分けて持つ、という意味となります。

Data Masking

fateにはData Maskingがあります。これは、Viewで宣言したフィールドしかコンポーネントから扱えない仕組みです。

Viewで選択していないフィールドは、コンポーネントから扱えません。

例えば、一覧用の PostListItemViewcontent を含めていない場合、一覧カードでは post.content を前提にした実装にできません。

これは一見すると制約のように見えますが、コンポーネントが暗黙に余計なデータへ依存しにくくなるため、保守性にはかなり効果的と私は感じています。

SuspenseとError Boundaryに寄せる

fateはAsync Reactを前提にしています。

データ取得中の状態はSuspenseに流し、エラーはError Boundaryに流します。

そのため、コンポーネント内で以下のような状態を毎回持つ設計とは少し違います。

if (isLoading) return <Loading />;
if (error) return <Error />;

もちろん、アプリによっては明示的に状態を扱いたい場面もあります。

ただ、画面単位のデータ取得では、Suspense / Error Boundaryに寄せられるのはかなり自然だと思います。

MutationはuseActionStateで書く

fateはデータ変更のための独自hookを提供しません。代わりに、ReactのuseActionStateをそのまま使います。

const fate = useFateClient();
const [result, like] = useActionState(fate.actions.post.like, null);

optimistic updateも、useActionState のactionに optimistic を渡すだけです。

const LikeButton = ({ post }) => {
  const fate = useFateClient();
  const [result, like] = useActionState(fate.actions.post.like, null);

  return (
    <button onClick={() => like({ input: { id: post.id }, optimistic: { likes: post.likes + 1 } })}>
      {result?.error ? 'Oops!' : 'Like'}
    </button>
  );
};

fate固有のmutation hookを覚える必要がなく、Reactが提供する useActionState の使い方を知っていればそのまま応用できます。

この既存のhookを知っていれば実装できるというのは素晴らしい設計思想だと思います。

AI Agentとの相性

fateはAI Agentとの相性も良さそうに感じました。

実はこれは偶然ではなく、fateの設計思想に明示的に含まれています。公式ブログでは以下のように述べられています。

As coding agents write more and more of our code, reducing imperative cache management and request-centric state handling becomes more important, not less. fate is designed to eliminate those patterns at the framework level.

つまり、命令的なキャッシュ管理やリクエスト中心の状態管理を減らすことは、coding agentsがコードを書く比率が高まるほど重要になる、という考え方がfateの設計に組み込まれています。

具体的には、データ構造や操作可能なmutationがかなり明示的であることが挙げられます。

  • client.generated.ts でroot / mutation / entity relationがわかる
  • client viewを見ると、コンポーネントが必要なデータがわかる
  • server DataViewを見ると、DBからどう解決されるかわかる
  • mutationを見ると、Live eventや権限チェックが追いやすい

また、正規化キャッシュにより、AI Agentがキャッシュキーの設計やキャッシュ更新漏れを気にする必要がありません。mutationを書けばEntityが自動で同期されるため、「この変更後にどのqueryをinvalidateすべきか」という判断を省けます。

さらに、mutationに useActionState を使っているため、AI AgentがReactの学習データをそのまま活用できます。fate固有のAPIを覚えさせる必要がなく、Reactを知っていれば書けます。

もちろん、AI Agentが client.generated.ts だけを見れば全てわかるわけではありません。

ただ、構造の明示性・キャッシュ管理の自動化・標準APIの活用という3点が重なることで、AI Agentが迷いにくい設計になっていると感じます。

fate 1.0で何が良くなったのか

ここまででもfateの哲学が非常に魅力的なことが理解できたと思います。
fateは 1.0では、より強力なアップデートがなされていました。

https://fate.technology/posts/fate-1.0

機能 概要
Live Views & Lists Server-Sent Events(SSE)によるリアルタイム更新。useLiveView / useLiveListView で対応
Drizzle support 1.0以前はprismaのみだったが、Drizzle adapterが追加された
native HTTP transport 1.0以前はtRPCのみサポートだったが、tRPC不要のHTTP transportとして、HonoなどにそのままマウントできるHandlerが提供された
Vite plugin 以前はCLI手動生成が必要だったが、サーバー型変更時に client.generated.ts を自動再生成するVite pluginが追加
Cache lifetime & GC 直近10リクエスト分を保持しつつ不要データを解放。ページ遷移の戻りを速くする
stable view refs 同じEntityのViewRefを安定させ、余分な再レンダリングを削減

中でも Live Views & Lists は昨今のリアルタイム機能の需要から知っておく必要があります。

Live Views & Lists

リアルタイム機能は useViewuseLiveView に置き換えるだけで実装できます。

これにより、そのEntityがSSE経由でリアルタイムに更新されるようになります。

const post = useView(PostDetailView, postRef);     // 自分の操作のみ反映
const post = useLiveView(PostDetailView, postRef); // 他クライアントの変更も反映

コメント一覧のようなlistには useLiveListView を使います。コメントの追加・削除がリアルタイムでlistに反映されます。

const [commentItems] = useLiveListView(
  postCommentsConnection,
  post.comments,
);

クライアント側でSSEを直接扱うコードは出てきません。

server側では、変更が起きたときに対象のEntityやlistを指定して通知します。

liveEventBus.update("Post", postId, {
  changed: ["likeCount", "likedByViewer"],
});

コメントの追加であれば、Post本体の更新と、Post.comments listへの追加を通知します。

liveEventBus.update("Post", postId, {
  changed: ["comments", "commentCount"],
});

liveEventBus
  .connection("Post.comments", { id: postId })
  .appendNode("Comment", commentId);

通知がrequestではなく正規化されたEntityやlistに紐づいているため、変更があったEntityを読んでいるコンポーネントだけが更新されます。

ここでもやはり、クエリ全体の再取得は不要です。

ここまでfateのコンセプトと1.0のアップデートを紹介しました。

ここからは、実際に作ったサンプルを見ながら、解説していければと思います。

作ったもの

https://github.com/sc30gsw/fate-hono-turso-sample

今回は、以下の機能を持つ簡単な投稿アプリを作りました。

  • ユーザー登録 / ログイン
  • 投稿作成
  • 投稿一覧
  • 投稿詳細
  • コメント追加 / 削除
  • いいね
  • Live更新
  • 投稿削除

技術スタックは以下となります。

  • Hono
  • Drizzle ORM
  • Turso / libSQL
  • Better Auth
  • React
  • TanStack Router
  • fate

ディレクトリ構成は、以下のようなモノレポ構成としています。

packages/
  api/
    src/
      fate.ts
      features/
        posts/
        comments/
        likes/
  client/
    src/
      features/
        posts/
        comments/
        likes/
      routes/
  db/
    src/
      schema.ts

このサンプルでのfate構成

このサンプルの実装では、fateの構成は以下のような関係となります。

PostDetailViewPostLikeView を分けているのがポイントです。
いいねのように更新頻度が高い部分は、小さいViewにしておくと影響範囲を絞りやすくなります。

View定義

以下がこのサンプルで実際に定義したViewとなります。

packages/client/src/features/posts/views/post-views.ts
import type { User } from "@app/api/features/auth/views";
import type { Comment } from "@app/api/features/comments/views";
import type { Post } from "@app/api/features/posts/views";
import { view } from "react-fate";

export const UserView = view<User>()({
  id: true,
  name: true,
  image: true,
});

const postSummarySelection = {
  id: true,
  title: true,
  createdAt: true,
  author: UserView,
  commentCount: true,
  likeCount: true,
} as const;

export const PostListItemView = view<Post>()(postSummarySelection);

export const PostLikeView = view<Post>()({
  id: true,
  likedByViewer: true,
  likeCount: true,
});

export const CommentView = view<Comment>()({
  id: true,
  content: true,
  createdAt: true,
  author: UserView,
});

export const postCommentsConnection = {
  args: { first: 10 },
  items: { node: CommentView },
  live: { append: "visible" },
} as const;

export const PostDetailView = view<Post>()({
  id: true,
  title: true,
  createdAt: true,
  author: UserView,
  commentCount: true,
  content: true,
  comments: postCommentsConnection,
});

ここからView単位で見ていきましょう。

fateで便利なのは、同じEntityでも用途ごとにViewを分けられることです。
投稿一覧では、一覧カードに必要なフィールドだけを選びます。

packages/client/src/features/posts/views/post-views.ts
export const PostListItemView = view<Post>()({
  id: true,
  title: true,
  createdAt: true,
  author: UserView,
  commentCount: true,
  likeCount: true,
});

詳細画面では、本文とコメント一覧も含めます。

packages/client/src/features/posts/views/post-views.ts
export const PostDetailView = view<Post>()({
  id: true,
  title: true,
  createdAt: true,
  author: UserView,
  commentCount: true,
  content: true,
  comments: postCommentsConnection,
});

いいねボタンのViewは、更新頻度が高いため専用の小さいViewに分けました。

packages/client/src/features/posts/views/post-views.ts
export const PostLikeView = view<Post>()({
  id: true,
  likedByViewer: true,
  likeCount: true,
});

最初は詳細画面の PostDetailViewlikeCount も入れていました。
しかし、いいね更新のたびに詳細画面全体が再評価され、詳細画面全体にレンダリングが走り、いいねの度に画面がちらつくといったUX的に良くない状態でした。

そこで、いいねボタン側だけで PostLikeView を読むように改善しました。

const { post: postRef } = useRequest({
  post: { id: postId, view: PostLikeView },
});

const post = useView(PostLikeView, postRef);

このように、Viewを小さく分けることで、更新範囲を狭くしやすくなりますし、キャッシュのことなどを考えずとも各コンポーネントに合わせてこういった調整が簡単に実現しやすいというのが非常に大きなポイントだと改めて思います。

APIのリクエスト設計から少し離れられる

通常のREST APIであれば、以下のようなAPIを考えることが多いと思います。

  • GET /posts
  • GET /posts/:id
  • GET /posts/:id/comments
  • POST /posts/:id/comments
  • POST /posts/:id/like

もちろん、この考え方もシンプルで良いです。

一方で、画面が増えてくると「一覧用API」「詳細用API」「一部だけ更新するAPI」「コメント込みAPI」などが増えやすくなります。

fateでは、server側でDataViewを定義しておき、client側はViewで必要なフィールドを選ぶことができます。

以下はserver側のDataView定義の抜粋です。

packages/api/src/features/posts/views.ts
export const postDataView = dataView<PostRow>("Post")({
  id: true,
  title: true,
  content: true,
  createdAt: true,
  author: userDataView,
  commentCount: computed(...),
  likeCount: computed(...),
  comments: list(commentDataView),
});

APIが不要になるわけではありません。ただ、画面ごとに細かいレスポンス形状のAPIを作るより、EntityとViewを中心に考えやすくなります。

これにより、新しい画面を追加するときも、server側のDataViewはそのままに、client側のViewを新たに定義するだけで対応できます。

Mutationとoptimistic update

fateでは、前述の通り、Reactの useActionState を使用してmutationを記述します。

packages/client/src/features/likes/components/like-button-content.tsx
import type { Post } from "@app/api/features/posts/views";
import { useActionState } from "react";
import { useFateClient } from "react-fate";

export type LikeButtonPost = Pick<Post, "id" | "likedByViewer" | "likeCount">;

export function LikeButtonContent({ post }: Record<"post", LikeButtonPost>) {
  const fate = useFateClient();
  const [result, like, isPending] = useActionState(fate.actions.likePost, null);

  const optimisticLikeCount = post.likedByViewer
    ? Math.max(0, post.likeCount - 1)
    : post.likeCount + 1;

  return (
    <button
      className="rounded border border-neutral-300 bg-white px-3 py-1 text-sm hover:bg-neutral-50 disabled:opacity-50"
      disabled={isPending}
      onClick={() =>
        void like({
          input: { postId: String(post.id) },
          optimistic: {
            likedByViewer: !post.likedByViewer,
            likeCount: optimisticLikeCount,
          },
        })
      }
      type="button"
    >
      {post.likedByViewer ? "♥" : "♡"} {post.likeCount}
    </button>
  );

いいねでは楽観的更新をしますが、こちらも簡単に実装できます。

void like({
  input: { postId: String(post.id) },
  optimistic: {
    likedByViewer: !post.likedByViewer,
    likeCount: optimisticLikeCount,
  },
});

この更新はfateの正規化キャッシュに反映されるため、同じEntityを読んでいるViewにも反映されます。

live機能を実装したページでは、useLiveView に渡す ref を useRequest で取得します。

function LiveLikeButton({ postId }: { postId: string }) {
  // useRequest で ref を取得し、useLiveView に渡す
  const { post: postRef } = useRequest({ post: { id: postId, view: PostLikeView } });
  const post = useLiveView(PostLikeView, postRef);

  return <LikeButtonContent post={post} />;
}

useLiveView はデータをfetchするのではなく、正規化キャッシュにあるEntityへの変更通知を受け取るフックです。

refはそのEntityがキャッシュのどこにあるかを指すポインタなので、先に useRequest でキャッシュに乗せておく必要があります。IDだけを渡す設計にしないのは、Data Maskingのために「どのViewで取得されたか」の情報が必要なためです。

ここまで実装を見てきましたが、fateを使う判断をするうえで、Reactのデータ取得で広く使われているTanStack Queryとの比較も整理しておきたいと思います。

TanStack Queryとの比較

TanStack Queryは非常に完成度が高く、APIリクエストを中心にした設計では今でも第一候補になりやすいです。

一方で、fateは少し考え方が違います。

観点 TanStack Query fate
中心 queryKey View / Entity
キャッシュ query単位 正規化されたEntity単位
Live更新 queryKeyに対してinvalidate/setQueryData Entityやconnectionに対してevent
データ要求 fetcher/APIごと Viewごと
optimistic update queryDataを意識することが多い mutation/actionとEntity cacheに統合
loading/error hookの状態を扱うことが多い Suspense / Error Boundaryに寄せる

例えばTanStack Queryでリアルタイム更新を扱う場合、以下のようなことを考えることが多いです。

  • queryKey をどう設計するか
  • WebSocket / SSE のイベントをどのqueryに反映するか
  • invalidateQueries するのか
  • setQueryData で手動更新するのか
  • 一覧と詳細の両方をどう同期するか

fateでは、以下のようにEntityやconnectionに対してeventを送ります。

liveEventBus.update("Post", postId, {
  changed: ["likeCount"],
});
liveEventBus
  .connection("Post.comments", { id: postId })
  .appendNode("Comment", commentId);

まとめると、TanStack Queryは「APIをどうキャッシュするか」が得意です。
一方、fateは「画面が必要なデータをどう宣言し、Entityとして同期するか」が得意です。

この違いはかなり大きいものなので、アプリケーションの要件に応じて使い分けていけると良さそうです。

例えば、TanStack Queryが向いているケースだと以下が挙げられます。

  • 既存のREST APIがあり、それをそのまま活かしたい
  • リアルタイム更新が不要、またはシンプルなinvalidateで十分

fateの場合は以下のようなケースが合っていると思います。

  • コンポーネントレベルで最適化したいケース(オーバーフェッチなく、パフォーマンスを上げたいケース)
  • リアルタイム更新があり、その他のリアルタイム機能を持つソリューション(Convex・Instant DB・TanStack DB・ElectricなどのSync Engineなど)が使えない場合

個人的には、Entity中心のデータ設計とリアルタイム更新を両立したいプロジェクトではfateはかなり有力な選択肢だと思います。

一方、既存のAPIがある場合や事例の多さを優先するならTanStack Queryが安定した選択肢となります。

気になったところ

最後に、実際に触ってみて、ハマった部分なども踏まえて、事前に知っておいたほうが良さそうな点について紹介します。

ViewRefの制約を理解する必要がある

例えば、PostDetailView で作られた postRefPostLikeView として読むとエラーになります。

Invalid view reference

この場合は、親Viewに必要なViewをspreadするか、別途 useRequest でそのView用のrefを取得する必要があります。

私の場合だと、いいねボタン側で PostLikeView 用に別requestする形にしました。

ボタンだけ小さいViewで読めるので、更新範囲も狭くしやすかったです。

これは仕組みとしては妥当ですが、最初は少し直感とズレる部分でした。

Live listは明示的なconnection eventが必要

Post本体の更新であれば以下でよいです。

liveEventBus.update("Post", postId);

しかし、コメント一覧のようなlistを更新する場合は、connection eventも必要です。

packages/api/src/features/comments/mutations.ts
liveEventBus
  .connection("Post.comments", { id: postId })
  .appendNode("Comment", commentId);

削除なら以下のようにする必要があります。

packages/api/src/features/comments/mutations.ts
liveEventBus
  .connection("Post.comments", { id: postId })
  .deleteEdge("Comment", commentId);

要するに、Entityの更新とlistの更新を分けて考える必要があるということですね。

TanStack QueryのqueryKey管理より楽に感じる部分もありますが、update() だけでlistも全部いい感じに更新されるわけではない点には注意が必要です。

複合主キーまわりは自前実装が必要になることがある

今回、like テーブルは postId + userId の複合主キーにしていました。

この場合、fateの count("likes") がうまく動かなかったため、likeCount は明示的にDrizzleでcountしました。

packages/api/src/features/posts/views.ts
likeCount: computed<PostRow, number>({
  resolve: async (item) => {
    const [row] = await db
      .select({ count: sqlCount() })
      .from(like)
      .where(eq(like.postId, item.id));

    return row?.count ?? 0;
  },
});

Drizzle adapterが多くの部分を解決してくれる一方で、こうしたケースでは明示的にSQLを書く場面もあります。

まだエコシステムは広くない

fate 1.0でDrizzle supportやnative HTTP transportが入り、かなり試しやすくなっています。

ただ、現時点ではTanStack Queryのように周辺知識や事例が多いわけではありません。
例えば、ElysiaJS向けの公式adapterはまだありません。

既存の大きなアプリに導入する場合は、まず一部の画面や新規機能で試すのが現実的だと思います。

まとめ

「APIリクエスト中心」ではなくCore Conceptsにある「View中心」でデータ取得を考える体験(Thinking in Views)は、実際に作ってみるとかなり納得感がありました。

気になる点(ViewRefの制約、Live listのconnection event、エコシステムの狭さ)はあるものの、Entity中心のキャッシュ設計とリアルタイム更新を自然に扱えるのはかなり強みです。

特に、AI Agentにコードを書かせる前提で考えると、client.generated.ts やView定義からアプリのデータ構造を読み取りやすい点やキャッシュキー管理・キャッシュ更新処理の実装漏れを気にしなくて良い点はかなり強みになりそうです。

このようなことから今後、fateのエコシステムが広がり、有力な選択肢になると私は考えています。

この機会にぜひ触ってみてはいかがでしょうか。
本記事が少しでも参考になれば幸いです。

参考文献

https://github.com/nkzw-tech/fate

https://fate.technology/guide/core-concepts

https://fate.technology/posts/fate-1.0

https://fate.technology/guide/live-views

Discussion