SolidJS とReact との違いを改めて整理した
- SolidJS 2.0
- Reactと、何が違うか
- useCallback / useMemo / React.memo が要らない
- useEffect の依存配列 vs createEffect の自動追跡
- リストは
.map()+ key ではなく<For> - 最初につまずく罠:
{cond && <div>}が使えない - 結局、何が楽になるのか
自分は SolidJS が好きで、個人的に作るアプリはほぼ SolidJS で作っている。
良い機会なので、改めて React と何が違うのかを整理してみた。
SolidJS 2.0
最近、SolidJS 2.0 の RC が出て、パフォーマンスが更に改善されるらしい。
SolidJSの メタフレームワーク SolidStart 2.0 も Stable になった。
Reactと、何が違うか
違いの根っこは
- コンポーネント関数が何度も実行されるか、一度きりか
- 仮想DOMを比較して差分検出するか、変更が起きた箇所だけ直接更新するか
この2点にコードの書き方の違いのほぼ全てが由来する。
React は
- state が変わるたびにコンポーネント関数がまるごと再実行される(re-render)
- JSX は毎回新しい仮想 DOM ツリーを作り、それを差分比較(diffing)して実 DOM に反映する
Solid は
- コンポーネントを一度だけ実行し、その後は変化したデータに依存する式だけが更新される。
- JSX はビルド時に DOM 操作の命令にコンパイルされ、コンポーネントの再実行・仮想DOM の差分比較・リコンサイラが不要。
// React: count が変わるたびに Counter() 関数自体が再実行される
function Counter() {
const [count, setCount] = useState(0);
console.log("re-render!"); // クリックのたびに毎回出る
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
// Solid: Counter() が呼ばれるのは最初の1回だけ
function Counter() {
const [count, setCount] = createSignal(0);
console.log("called once"); // 1回しか出ない
return <button onClick={() => setCount(count() + 1)}>Count: {count()}</button>;
}
count() と関数呼び出しになっているのがポイント。
JSX 内の count() はコンパイラによって「この DOM ノードのテキストはこのシグナルに依存する」という直接的な購読に変換される。Solid は各 DOM ノードが何に依存しているかを追跡し、必要な部分だけを更新する。
useCallback / useMemo / React.memo が要らない
React のコードが複雑になる最大の原因はここだと思う。関数が毎回再実行される以上、
内部で定義した関数はレンダリングごとに新しいインスタンスになる。
- 子に渡すと props が毎回変わったと判定され、不要な部分まで再レンダリングを引き起こす
- → 再レンダリングを防ぐために、
useCallbackが必要
- → 再レンダリングを防ぐために、
- 計算コストの高い処理も毎回走る
- →
useMemoが必要
- →
- 子コンポーネントの不要な再レンダリングも発生する
- 防ぐには
React.memoで包む必要がある
- 防ぐには
Solid はコンポーネントが1回しか実行されないので、この種の最適化がそもそも構造的に不要だ。依存配列も useCallback も memo も要らない。
React で500行の編集可能テーブルを作ったりすると、onUpdate コールバックの参照が親の再レンダリングのたびに変わって memo が壊れ、useCallback が必要になり、その useCallback が setRows を依存配列に要求し…という連鎖にはまったりする。
Solid ではコールバックが state セッターを直接参照するため参照が安定しており、この種の問題自体が起きない。
useEffect の依存配列 vs createEffect の自動追跡
React の useEffect(fn, [deps]) は「この effect はこれらの値に依存する」と手動で申告する仕組みだ。申告漏れは stale closure(古い値を掴んだまま)のバグを生む。
// React
useEffect(() => {
console.log(count); // count を使っているので依存配列に書く必要がある
}, [count]); // 書き忘れるとバグる
// Solid
createEffect(() => {
console.log(count()); // count() を「呼んだ」時点で自動的に依存関係として登録される
});
React の effect は間接的で、再レンダリングと依存配列によって制御される。一方 Solid の createEffect は直接的で、初期化時にリアクティブグラフを構築し、count が変化すれば依存する計算だけがトリガーされる。
地味に見えるが、React でよくある「依存配列の警告と格闘する」という体験自体が Solid では発生しない。
リストは .map() + key ではなく <For>
React は配列を .map() して key prop を手動で付ける必要があり、key の選び方を間違えると(index を key にする等)バグの温床になる。
Solid は <For> コンポーネントを使う。これは配列の「参照」ではなく要素単位で差分を取る設計だ。
// React
{items.map(item => <Row key={item.id} data={item} />)}
// Solid
<For each={items()}>
{(item) => <Row data={item} />}
</For>
<For> はデフォルトでキー付けされており、各行は一度しかレンダリングされない。DOM 要素そのものを再利用する仕組みなので、React で頻発する「key の付け方問題」自体が起きにくい。
最初につまずく罠:{cond && <div>} が使えない
Solid を学ぶ人が最初につまずくポイントがこれだ。React では普通にやる書き方——
// React: これで OK。render のたびに評価されるから
{isLoggedIn && <Dashboard />}
{items.length > 0 ? <List items={items} /> : <Empty />}
Solid ではコンポーネント関数が一度しか実行されないため、素の && や三項演算子を使うと「最初の1回だけ評価されてそれっきり」になってしまう。だから専用の <Show> コンポーネントを使う。
// Solid
<Show when={isLoggedIn()} fallback={<Login />}>
<Dashboard />
</Show>
これは Solid の「不便な点」というより、「一度しか実行されない」という設計を貫くために生まれた、React との本質的な違いの現れだ。
結局、何が楽になるのか
| 観点 | React | Solid | 楽になる理由 |
|---|---|---|---|
| 再実行 | state 変更ごとに関数全体を再実行 | 初回のみ | 「なぜ再レンダリングされたか」を追う認知コストが消える |
| メモ化 | useCallback/useMemo/memo が実質必須 | 基本不要 | 最適化のための定型コードを書かなくていい |
| Effect | 依存配列を手動管理、書き漏れでバグる | 使った値を自動追跡 | 依存配列との格闘がなくなる |
| リスト | key を手動設計 | <For> が要素単位で差分 |
key 選定ミスによるバグが起きにくい |
| バンドル | 仮想 DOM 実装込み | 仮想 DOM が不要な分軽量(約 7kb gzip) | 素直に軽い |
一言でまとめると、React は「変更があったら全部作り直して差分を取る」設計で、その代償として「再実行される前提でコードを書く」ための道具(useCallback 等)が大量に必要になっている。Solid は「変更が起きた場所だけを直接更新する」設計にすることで、その道具立て自体を丸ごと不要にした。これが本質的な違いだと思う。
エコシステムの大きさや採用実績では React が依然圧倒的に優位だ。
自分も、さすがに今更、ReactのアプリをSolidJS に書き直すべきとは思わないが、
Solidで書いたほうがかなり楽に書けるものは多いと思う。
Vue に慣れている人なんかは、コンポーネント関数が1回だけ実行されるという点で、SolidJS に移行するのが意外とスムーズかもしれない。