🧞‍♂️

Hello, Solid 2.0 ! —— use() も startTransition もない Async React !?

に公開

はじめに

Solid 2.0 が RC になりました。公式ブログを読みつつ触ってみましたので共有します。

https://www.solidjs.com/blog/solid-2-0-rc-the-big-reveal

触ってみた感想を先に言うと、「これ Async React だな」でした。React が use()startTransitionSuspense に分けて積み上げてきたことを、Solid 2.0 は いつもの createMemo(React でいう useMemo みたいな派生値。)が Promise を返せるという一点でやっています。

この記事では、まず Async React とは何だったかを振り返ってから、React の API と比較しつつ Solid 2.0 の async を見ていきます。コンパイラが Rust になって速くなった話とかは、今回はあんまり触れません。

Async Reactって?

React 側の歴史を先に押さえておきます。

React Conf 2025 で、React チームの Ricky Hanlon が「Async React」というタイトルの講演をしました。

一言でいうと、アプリケーションをデフォルトで非同期とみなして構築する考え方です。
講演では await UI = await f(await state) と表現されていました。
対比として、いまの React アプリは Sync React と呼ばれています。

いくら速くしても、ネットワーク待ちがゼロになるわけではない。だから未準備の状態を UI としてどう見せるかを、フレームワークが調整する必要がある、という話です。

講演の中で、これらの API を手動で組み合わせるのは複雑すぎるという問題提起がされています。コンポーネントを細かく Suspense で囲い、ボタンには useOptimistic と action prop を実装し……は開発体験が悪い。なので React チームは、コンポーネントライブラリ・ルーター・データフェッチングライブラリの 3 レイヤーに Async React を組み込んでいく方針で、Async React Working Group を設立しました。

ここからが本題です。Solid 2.0 は、同じビジョンに対して別の答えを出しました。

Solid 2.0 はどう解いたか

Solid 1.x の構造は React と似ていました。同期の細粒度リアクティビティが本体で、async は createResource という別の primitive が受け持つ。SuspenseuseTransition もありました。

2.0 はそこを畳みました。API を組み合わせて調整するのではなく、async をリアクティブグラフの性質にして、調整をモデルに組み込んだ、という方向です。計算そのものが Promise を返せます。

同じ「id でプロフィールを取る」を、1.x と 2.0 で並べます。

Solid 1.x

const [id, setId] = createSignal(1)
const [user] = createResource(id, fetchProfile)

return (
  <ErrorBoundary
    fallback={(error, reset) => (
      <section>
        <p>{String(error)}</p>
        <button type="button" onClick={reset}>Reset</button>
      </section>
    )}
  >
    <Suspense fallback={<p>Loading profile…</p>}>
      <Show when={user()}>
        {(u) => (
          <article classList={{ updating: user.loading }}>
            <h2>{u().name}</h2>
            <p>{u().bio}</p>
          </article>
        )}
      </Show>
    </Suspense>
  </ErrorBoundary>
)

Solid 2.0

const [id, setId] = createSignal(1)
const user = createMemo(() => fetchProfile(id()))

return (
  <Errored
    fallback={(error, reset) => (
      <section>
        <p>{String(error())}</p>
        <button type="button" onClick={() => { setId(1); reset() }}>
          Reset
        </button>
      </section>
    )}
  >
    <Loading fallback={<p>Loading profile…</p>}>
      <article class={{ updating: isPending(user) }}>
        <h2>{user().name}</h2>
        <p>{user().bio}</p>
      </article>
    </Loading>
  </Errored>
)
  1. いつものcreateMemoの中で fetch する。戻り値は Promise
  2. 初回はまだ答えがないので、<Loading>が fallback を出す
  3. id が変わると次の fetch が始まる。古いプロフィールは残る(ちらつかない)
  4. isPending(user)は「どこかがロード中」ではなく「この user の次の答えが飛行中」(過剰なフィードバックにならない)
  5. 失敗はグラフを流れて<Errored>に届く

1.x との差でいうと、

  • createResourceが消えて、普通のcreateMemoになった
  • user() が T | undefinedではなく、準備できてから読める。だからShowで絞らなくていい
  • user.loadingの代わりがisPending(user)。しかも「この問いについて」なので、関係ないところまで pending にならない
  • Suspense / ErrorBoundary<Loading> / <Errored> に名前が変わっただけ、に見えるけど、未準備がグラフの状態になったので境界の意味がはっきりした

Sync React の 2 大問題としてデモが見せていた「ちらつき」と「過剰なローディング」が、2.0 ではデフォルトの挙動で潰れているのがポイントです。

React との対応はこうなります。

React Solid 1.x Solid 2.0
use(promise) createResource async な createMemo を普通に読む
Suspense Suspense <Loading>
useTransition の isPending useTransition / user.loading isPending(user)
useOptimistic 自前 or ライブラリ action + createOptimisticStore
Server Actions SolidStart の "use server" コアの "use server"

「React を真似した」というより、同じビジョンを リアクティブグラフ(signal と memo の依存関係)の側で解いた、という感じです。1.x は React と同じく、async を createResource みたいな別 primitive で受け止めていました。2.0 ではそれが、いつもの createMemo の戻り値になった、と捉えると分かりやすいです。

書き込みも同じモデルに乗る

ここまでは読む側の話でした。書く側、つまり mutation はどうなるか。2.0 では actioncreateOptimisticStore がコアに入っています。

import { action, createOptimisticStore, refresh } from "solid-js"

const [todos, setTodos] = createOptimisticStore<Todo[]>(
  async () => listTodos(),
  [],
)

const toggle = action(function* (id: string, completed: boolean) {
  setTodos((draft) => {
    const todo = draft.find((item) => item.id === id)
    if (!todo) return
    todo.completed = completed
  })
  yield toggleTodo(id, completed)
  refresh(todos)
})

action は generator で、yield がサーバー待ちです。流れはこうです。

  1. yield の前に draft へ書く。これは仮の値(overlay)として すぐ画面に出る
  2. yield でサーバーの応答を待つ
  3. action が終わると overlay が外れて、refresh(todos) が正本と突き合わせる
  4. 失敗したら overlay ごと戻る

React で同じことをするとuseOptimistic + startTransition の組み合わせになります(Async React デモの CompleteButton がまさにそれです)。Solid 2.0 では楽観値の寿命が action のトランザクションに紐づいているので、ここでも包む API が出てきません。読み側で startTransition が消えたのと同じ理屈です。

サーバーでも同じ読み方

もうひとつ、2.0 で "use server" がコアに入りました。

import { query } from "@solidjs/router"

export const getUser = query(async (id: string) => {
  "use server"
  return findUser(id)
}, "user")
const user = createMemo(() => getUser(props.params.id))

読む側は、最初の例とまったく同じuser().nameです。サーバー上では普通の関数呼び出し、ブラウザではコンパイラが typed fetch に差し替えてくれます。client fetch でも SSR でも server function でも、コンポーネントの書き方が変わりません。async がグラフの状態なので、答えがどこから来るかはコンポーネントの知ったことではない、というわけです。

おまけに、フォーム POST は hydration 前でも動きます。mutation すると再検証データが同じ POST の応答に乗ってくる(single-flight)ので、ネットワークタブを見ると POST 1 本で画面全体が更新されて気持ちいいです。

ちなみにこれに伴って SolidStart は退役して、Vite プラグインの start mode が後継になりました。この話も大きいんですが、今回は触れません。RSC 相当も experimental preview の段階です。

ただし、境界の外で読むとダメ

触っていて実際に踏んだやつです。

<Title>(head に出るタグ)の中で async な user().name を読んだら、開発サーバーが落ちました。head は body 側の <Loading> の外なので、「まだ答えがない」という状態がどの境界にも届かず、SSR のストリームが終わらなくなったためです。(それはそう)

async がグラフの状態になったからといって、どこで読んでも同じ、ではないです。未準備の読みは <Loading> の内側に置く、というルールは残ります。ここはSuspenseの感覚と同じでした。

まとめ

React Conf 2025 の Async React は、「調整のための API はある。手で組み合わせるには複雑だから、エコシステムで抽象化していく」という宣言でした。

Solid 2.0 は、同じ問いに反対側から答えています。組み合わせを抽象化するのではなく、調整そのものをリアクティブグラフのデフォルトにする。
だから、use() がない、startTransition がない、createResource もない、という見た目になります。足りないのではなく、包むものが要らなくなった、です。

もちろん、React にはエコシステムと Concurrent の実績があり、Async React Working Group もこれからです。すべての画面にこのモデルが必要なわけでもありません。同期だけの UI なら今まで通りで十分です。

ただ、fetch と pending の管理がだんだん分かれてきた、startTransition をどこで包むか考え始めた、「この loading、誰の loading?」となり始めた、そんなタイミングで Solid 2.0 を触ると、Async React が目指している世界を先に体験できる感じがあります。

最後まで読んでいただきありがとうございました。ぜひ皆さんも触ってみてください。

Discussion