🧊

脱 React, Solid.jsを初めてみた

2024/03/04に公開

最近Solid.jsを使ってみた、Reactとは似ているが、多少違う、より強力であることが感じれた。

Solidって何なの?

Reactのようなフロントエンドフレームワークである。2021年正式にリリースされた、新しいものであり、十分安定しているようなライブラリーである。

基本的に宣言的にかけるのはReactと同様で、JSXを使う。

function MyComponent(props) {
  return <div>Hello {props.name}</div>;
}

<MyComponent name="Solid" />;

但し、状態管理は多少違う。ReactのHookのように読み込み、書き込みのインタフェースが分けられているが、Reactvitiy(反応性)を持ったSignalというものが代わりに存在し、これが思ったより大きい差を作る。

// React
const [status, setStatus] = useState()
const processStatus = useCallback(() => {
	doSomethingWith(status)
// 依存性うぜえ、、
}, [status])

// Solid
const [status, setStatus] = createSignal()

// 依存性なんか要らん
const processStatus = () => {
  doSomethingWith(status())
}

// Effect Hookにも同じく要らん
createEffect(() => {
  doSomethingWith(status())
})

さて、依存性もないのにどうやって値が変わることがわかるの?コールバック更新はどうするの?と思うかもしれないが、Solidの状態管理で使われるSignalは純粋なObjectではなく、Proxyから作られた特殊なObjectである。ProxyはObjectのGetterとSetterをハイジャックし、それの属性の値が読まれたり、変えられたり時にEffectを入れられるようにしてくれる。
つまり、あなたが宣言的に使うと決めた状態は、ProxyであるSignalがEffectを使い、どこに何が使われたのかを正確に覚えて、その値が変わったらそれが使われた部分のみ精密にアップデートをしてくれる。

さて、なぜReact辞めたいのか?

性能

Reactが出たばかりの頃だったらSolidのようなものを使うことは遠慮していただろう。当時はProxyがそもそもちゃんと具現化されていなかった。しかし今は違う。Proxyはほとんどの重要ブラウザに既に具現されて、十分安定的である。さらに性能にも問題ない。Vue.jsもv3からProxyを使い、当然、性能的に優れている。

なぜReactは劣るのか?

VDOMを使うせいだ。Reactが出たばかりはVDOMを使うことで性能的な利点がある場合もあった。Reactは実際のHTML Elementに変更を与える前に、VDOMから変更を確認し、変更がある場合のみHTML Elementを操作する。今と比べると昔のブラウザはいろんな面で劣っているため、DOMを変更するレンダリングコストが相対的に多かった。故に、Javascript段で全てを準備し最低限の操作をすることが有利なケースもあった。同時に、当時はES5すらちゃんと導入されていなかったから、ブラウザごとのAPIの断片化(Fragmentation)もすごかった時代だった。故に、VDOMを入れることは開発者がこういった断片化に気にしなくても開発ができる環境を提供してくれた。
しかし、今は違う。今のブラウザはレンダリングも最適化されているし、断片化もほぼなくなった。今の時点で別のツリーを作り、値を比較した後からDOMを操作することはメモリ的にも、コンピューティングコスト的にも非効率的なやり方になってしまった。
その一方、SolidはSignalを使いDOMを直接操作する。値が変わったら、Proxyを通じて、そのProxyと繋がっているDOMを直接操作する。
つまり、Reactは明治時代の電話交換手みたいなものを通じてDOMを操作する一方、
Solidは直通Hot Lineを設置してくれる。

Reactの方向性においての失望

Next.jsが流行るほどReactの方向性は変になっていく。MetaがMeta verseでエネルギーを無駄にしている間、OSSへの動力はだんだん減り、Reactの開発はだんだんVercelの影響が大きくなっている気がする。自然にサーバサイドレンダリングへの関心が大きくなり、本来のFrontend frameworkでの役割は後回しになったよう。
レンダリング性能とDXは他のものと比べ劣っているのが目み見えているのに、Reactチームはあまりこれに関心がなさそう。
もちろん、えぐいほどのトラフィックが発生する企業(Vercel, Meta など)の立場からするとSSRの改善からできる利益が十分あったかもしれない。しかし、ちょっとした速さがあなたの顧客に本当に問題になるのか?

https://twitter.com/tomus_sherman/status/1681355056950525963

今日本では5Gが使えて、ダウンロード速度は20gb/sである。
それの1000000分の一単位の数kbなんかって、本当にあなたの顧客を邪魔するのか?
日本のユーザに60kbのコンテンツが8kbになったとして、本当に性能が体感できると思うのか?
またほとんどの場合は毎回壊れるNext.jsのAppRouterをギリギリ使いながら、毎回次のVersionによりMigrationのため開発チームが1~2週間時間を無駄にする費用が、サーバトラフィックにより発生する費用よりはるかに多いはずだ。

もちろん、土地が広すぎて、電波が届かないところも多いし、ネット回線も揃えられていないアメリカとか、全世界のインタネットインフラが劣悪な国の人にサービスを提供する場合だと妥当かもしれない。
しかし、あなたのサービスはどうなのか?あなたの顧客はYoutubeすら見れないほどインフラが劣悪な環境に住んでいるのか?

さらに、Next.jsはサーバサイドレンダリングの段階で、Node.jsを直接アクセスできるようにしている。これに気になる部分は保安の問題だ、サーバサイドのみ使われるべきのコードがクライアント側に使われた時など。
Next.jsはリリース時点からバグだらけだったが、さてここに問題が起きない保証は可能なのか?Next.jsが保安的に完璧だと仮定してみよう。では、それを使う開発側はミスらないとどう補償するのか?少人数の開発チームや1人チームとかだと抑えるかもしれない。しかしCTOとかの立場からすると、数十、数百、数千の開発者がこういうミスをしないと補償できるのか?現実的に不可能だ。いずれかはESLintや、SASTツール(Static Application Security Testing,ソースコード保安分析、Github Code QL, Snyk Code, Semgrepなどが有名)がある程度こういう問題を抑えてくれるが、それは未来の話だ。また、ツールがあっても開発者のミスを減らしてくれるだけで、保証はしてくれない。結局安全性が確保されるまで(Battle Tested)後数年はかかる。

つまり、まだ成熟されていないテックスタックの仕様によるMigration費用と潜在的な保安問題を、あなたはただ数KBのペイロードを減らすために正当化できるのか?
ゆえに、私はReactの方向性に今はついて行きたくない。数年後にRSCが十分安定化したら考えが変わるかもしれないが今は不快感しかない。

開発経験

React Hookのリリースは本当にすごかった。しかし、数年間使いながらExhausted Dependencies問題は開発経験上マジで最悪だった。イベントハンドラなどはいつもMemoiz Hook, useCallbackを使わないとレンダリング性能は最悪になり、もしこういうハンドラが深いところまで渡してしまうと、開発経験は本当に最悪になる。
さて、Reactがこれに対し改善入れたことがあるの?
外部ではMobX、Preact Signalなど様々な対策ができたが、前述の通り、今のReactチームはこの方向には興味がなさそう。もちろん最近の記事にはReact Compilerが開発中でHook Dependenciesなどの問題を解決しようとする動きがみえる。しかし、、、あなたは今の開発環境をいずれかReact Compilerを作り直す準備はできているのか?
https://react.dev/blog/2024/02/15/react-labs-what-we-have-been-working-on-february-2024

Signalにはこういった問題がない。最初紹介したように、依存性を手動に操作する必要が全くない。全てはあなたが作成したコードから勝手に抑えてくれる。
さらに、SignalはReactのHookみたいなくだらない規則がない。全域でSignalを作ると全域状態が作れる。Reactのようにコンテキストを作り、プロバイダーを提供する必要なんかない。もちろん場合によってはコンテキストを作る必要があるが、Solidも同じくContextを作ることができる。
それに、条件文には作ることができないReact Hookと違い、Signalには何の制約もない。レンダーごとに何個のSignalが作られるか全部同じく綺麗に片付けてくれる。
もっとすごいのはコールバックの中にSignalを作っても、問題なく動き、たくに開発者が気をつけることもない。

// Hookは全域で作ることができないが、Signalはできる。
const [globalState, setGlobalState] = createSignal()

const MyComp = () => {

  if (...) {
  // 条件文の中にSignalを作ってもいい
    const [todos, setTodos] = createSignal([])

	const addTodo = (text) => {
	  // さらにコールバックから作って、他のSignalの状態として渡しても問題なく動く!(Nested Signal)
      const [done, setDone] = createSignal()
		
	  setTodos([...todos(), {id: uuid(), text, done, setDone}])
	}

	const toggleTodo = (id) => {
	  const todo = todos().find(todo => todo.id === id)
	  if (todo != null) {
	    // Nested Signalはそのまま使える!
		todo.setDone(!todo.done())
	  }
	}
	return (...)
  }

  return (...)
}

つまり、SolidはAPI設計から開発者がミスる余地を上げないが、
ReactのHookは古いAPI設計により開発者のミスが発生する余地があり、規則を強制している。
https://react.dev/warnings/invalid-hook-call-warning

同時に配列をレンダリングするときに必要なkeyがSolidはいらない。
Reactの場合、Objectの値が変わるといつも新しいObjectを作る必要があり、これを特定しようとするとどのElementが変わったのかを特定するため、keyを必ず入れる必要があるが、
Solidはこれがいらない。KeyはObjectのレファレンスであり、レファレンスの値が変わるとその項目だけ変えることができて、そもそも配列を改めてイテレーションする必要すらない

// React
// itemの中で一つでも変わったらlistが変わるので、mapはもちろんレンダリング関数が全部再実行される。
return <>{list.map(item => <div key={item.id}>{item.name}</div>)}</>
// Solid
// itemが変わっても関係のある<div>のみ直接操作し、イテレーションがそもそも発生しない。
// 故に開発経験も性能も両方とも優れている。
return <>{list().map(item => <div>{item.name}</div>)}</>

さらに、レンダリング段階で精密なコントロールを提供してくれる。
React Hook利用し、他のライブラリで作られたコンポーネント(Wyswyg Editorなど)を操作するとき、Hook固有のLifecycleのせいで、State Hookは使えず、Refなどで精密にコントロールをする必要があるが、Solidはいつでもレンダリングする時点やデータが変わる時点を開発者がコントロールできるようにしてくれる。
untrack 関数は状態が変わってもレンダリングを防いでくれる。
batch 関数は複数のステータス変更がどこまで同時に発生するべきかを決めることができる(昔のReactはsetStateごとにレンダリングをし、今はsetStateをまとめてからレンダリングをする、故にEffectが実行されるタイミングのコントロールができない。)
on 関数はEffectが使うすべてのSignalに反応するのではなく、(React Effect Hookの依存性設定のように)特定値が変わった時のみEffectを実行してくれる。
また、ReduxなどのReactivityのない第3の状態管理ライブラリを使う場合を考えてreconcile機能も提供している。(もしReduxをかなり重く使うアプリであれば、マイグレーションがちょっと楽かも?)

さらに、実際に開発に必要なHelperやモジュールも直接開発し、細々なところまで開発者のために気を使っていることが感じれる。
createResourceはHTTP Requestなどの非同期的な方式でデータを取ってくるときに必要な状態管理と関数を用意してくれて、毎回頑張りすぎなくてもいい。
Solidの公式RouterモジュールはReact RouterやNext.jsのように<Link>を持ちいらなくていい。ほとんどの場合は<a>をそのまま使うだけで、問題なく動く.

トレードオフ

書き方の差

ただし、既存のReactプロジェクトにSolidを入れるには多少ハードルがある。Reactivityを積極的に使うため、JSXでコンポーネントを作るが、Reactと互換性が劣る。

この場合Preactを使って、妥協することもできるが、PreactはReactの互換性を重視しているため、Solidよりは性能が劣る。

Reactivityのため状態はすべてSignalから管理される必要があり、
状態を呼び出すにはGetter関数から呼び出さなきゃならない。

// React
const [msg, setMsg] = useState('Hello')

// 純粋な値であるのでそのまま使える。
handleMsg(msg)

return (
  <div>{msg}</div>
)

// Solid.js
const [msg, setMsg] = createSignal('Hello')

// msgは Signalのgetterであるため、実行してから値が得られる。
handleMsg(msg())

// ただし、JSX Elementに渡すときはGetter関数そのまま渡してもよい。Solidがレンダリングするときに勝手にSignalであることを確認し、値を持ってくれる。
return (
  <div>{msg}</div>
  // 当然、開発者が直接値を持ってきても構わない。
  // <div>{msg()}</div>
)

また、コンポーネントのプロパティのSplitting、Mergingに制約ができる。
親からもらうpropsは純粋なObjectではない。ReactivityのためProxyになるので、({ msg }) => ...のようにプロパティを分けたり {...someObj, msg}合わせたりすると, ProxyによるReactivityは無くなってしまう。

// Reactは分けてもいいが、
const MyComponent = ({ msg }) => { handleMsg(msg) }
// Solidでは分けてはいかない。
const MyComponent = (props) => { handleMsg(props.msg)}

もちろん、このためSolidからsplitPropsmergePropsなどの関数を提供しているが、呼び出すことに手間がかかる。

同じ理由でJSXのchildrenの活用にも小さい制約が発生する。

// 普通にChildrenを他のコンポーネントに渡したり、レンダリングさせることは全然問題ない。
const Comp = (props) => {
  return (
    <div>{props.children}</div>
  )
}

import { children } from 'solid-js'

const FilteredList = (props) => {
// しかし、props.childrenが複数の場所で使われると、Memoize Helperである`children`で一回囲んであげる必要がある。
// 但し、これは一つ以上の場所から`props.children`を利用するとき、Effectやレンダリングの時に一回以上呼ばれる時のみ必要とする。
  const c = children(() => props.children)
  
  createEffect(() => c())
  
  return <div>{c()}</div>
}

エコシステム

他の問題は正式に出たのが2021年であるから、エコシステムがちょっと弱いこと?

例えば、ReactにはNext.jsがあるが、Solidはまだそれに対応するSolid Startがまだベター状態である。しかし、Next.jsなんかなくてもSSRは難しくない。Next.jsがちょっと便利にしてはくれるが、自ら弱虫になる必要はない!SSR具現に実際にあなたに必要であることはバンドルをホスティングするStaticファイルルートと、ページごとにバンドルを作ってくれるrenderToStringを使うルートのみ用意すればいい。その後アプリの機能がそろえたら、性能改善のため一部重いページやコンポーネントにDynamic importを追加するだけで十分である。秘伝の裏技なんか要らない。

import express from "express";
import url from "url";

import { renderToString } from "solid-js/web";
import App from "../shared/src/components/App";

const app = express();
const port = 8080;

// バンドルをホスティング
app.use(express.static(url.fileURLToPath(new URL("../public", import.meta.url))));

app.get("*", (req, res) => {
  let html;
  try {
    // ただただAppをレンらリングし、Responseに渡せばいい。
    html = renderToString(() => <App url={req.url} />);
  } catch (err) {
    console.error(err);
  } finally {
    res.send(html);
  }
});

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

https://github.com/solidjs/solid/blob/main/packages/solid-ssr/examples/ssr/index.js


とりあえず、上記の理由で、当分Reactとは距離を置くつもりである。
ハゲになりたくないから。

(Korean, 한국어) https://velog.io/@rokt33r/脱-React-Solid.js를-써보다

Discussion