React = 関数型?
Webのフロントエンドフレームワークとして有名なReactは関数型だと言われます。
「ふーんそう、まあ、関数型プログラミングのことなんか知らなくても問題なく使えてるよ」
という人も多いのではないでしょうか。
実際、世の中にサンプルコードはたくさん存在しますし、関数型について改めて勉強しなくてもアプリケーションは作れます。しかし、MetaがReactを関数型として設計している意図[1]を汲み取って、迷わず、効率よく、楽に書けるほうがお得だと思うのです。
関数コンポーネントと純粋関数の関係
関数型プログラミングは純粋関数を扱うものです。Reactの関数コンポーネントは純粋関数でしょうか?
純粋関数のおさらい
この連載の中で何度も書いていますが、純粋関数は
- 引数以外の入力が無い
- 返り値以外の出力(副作用)が無い
- 引数が同じなら返り値は常に同じ
という関数でした。
React.FC: 関数コンポーネント
FCはFunctional Component、関数(関数型?)コンポーネントですね。例えばこんなコードです。(右側のノブをクリックすると実行結果を表示)
この関数MyEditor
は純粋関数でしょうか?
(1) 返り値はHTMLに見えますが、ブラウザに紐付くDOMではなくJSXで、単なるメモリ上のオブジェクトです。このオブジェクトを生成する際にブラウザの機能にアクセスするといった副作用はありません。🥰
(2) イベントリスナhandleChange
は副作用ではありません。MyEditor
関数を実行している時点では、handleChanges
関数を作ってJSXに含めて返しているだけです。🥰
(3) 中でReact.useState
フックを呼んでいます。MyEditor
を同じ引数で何度も実行する場合、useState
が返すvalue
の値(State)は前回setValue
でセットした値です。つまり引数以外の入力があり、同じ引数を渡しても同じ返り値になりません。😑
したがって、MyEditor
は定義上、純粋関数ではありません。おしい!
useStateフックのおさらい
でも、もう少し考えてみます。
useState
にはReact hooksの制約があります。
- 条件分岐やループの中では呼べない
つまりuseState
が返すvalue
(State)はMyEditor
関数中で必ず1回だけ与えられるため、引数と近い性質を持っていると考えられます。
さらにもう一つの制約
- Stateを変更する関数
setValue(...)
はMyEditor
関数実行中には呼べない
により、MyEditor
関数の実行中に副作用を起こすこともできません。ということは、MyEditor
は純粋関数に近い性質を持つと言えなくもないような気がします。
といったわけで、このような関数コンポーネントを「(ほぼ)純粋関数」と呼ぶことにします。
(ほぼ)純粋関数同士を組み合わせても、もちろん(ほぼ)純粋関数です。
useStateフックをもっとちゃんと理解する
useState
が返すsetterであるsetValue(...)
を実行すると
という処理が走ります。
ぼんやりとした理解ですと、Reactが魔法のようにうまいこと<input type="text" value={value} />
のvalue
の部分だけ変更してくれてるのかなあ、などと思いがちですが、魔法なんてありません。単に関数を頭から実行し直しているだけです。結果、返すJSXのvalue
部分だけが前回と違うのでDOMのvalue
部分だけが変更されるのです。
この例で言えば<input type="text">
に1文字入力するたびにこのフローが全部実行されます。
無駄な処理が多いと思うでしょうか?しかしフロントエンドの処理において、パフォーマンスのボトルネックはDOMの操作です。DOM操作を伴わず、メモリ上だけで行われる純粋関数の処理は多少無駄に繰り返してもユーザー体験を損なわないのです。
関数コンポーネントがほぼ純粋関数であるとは
ここまで、純粋関数だ、副作用がない、という言葉を使ってきましたが、フロントエンドの文脈ではこう言ったほうが伝わりやすいでしょう。
jQueryコーディング中にプログラマが考えていたこと
Reactとの対比のため、上の例と似たようなことをjQueryで書いてみます。
<input id="name" type="text" value="初期値">
<div>valueの値=<span id="display"></span></div>
<script>
const input = $("#name");
const disp = $("#display");
// 更新系メソッドを呼んで表示する
disp.text(input.value);
input.change((e) => {
// イベントハンドラでDOMを直接更新する
disp.text(e.target.value)
});
</script>
React以前、フロントエンドのプログラマは常に
- ○○のイベントが起こったらDOMの□□を更新する
ということを考えていました。例示したコードはあまりに直接的ですが、KnockoutやBackboneなどObservableを利用するようなMVCアーキテクチャであっても、プログラマはいつもイベントとDOMの更新を意識していたし、HTMLの要素中にそこを更新するための仕掛けとかを入れていたと思います。
Reactコーディング中にプログラマが考えていること
しかしReact(を含むFLUX系フレームワーク)では、まずHTMLの構築は次のコードのように
:
<div>valueの値={value}</div>
:
- 渡されたStateの値をHTMLに差し込んで表示する
ということを考えます。DOM更新のコードは書きません。表示だけのほうが書きやすいのは自明ですね。
そしてイベントハンドラでは、以下のコードのように
const handleChange = (e) => setValue(e.target.value)}
:
<input ... onChange={handleChange}>
:
- ○○が起こったら(DOMではなく)Stateを上書きする
と考えてStateのsetter関数を呼んでいます。Stateの値はイミュータブルが前提ですので、部分的な変更はせず、潔く上書きします。
もしReactを書いていて(□□を更新するにはどうすれば…)と考えて難しさを感じていたなら、考え方のほうをアップデートすると楽になるかもしれません。
コンポーネントが(ほぼ)純粋関数だと何が嬉しいか
表示のコードしか書かないというだけでもうだいぶ嬉しいのですが、純粋関数はテストしやすい!(100回目)ので、Storybookでコンポーネント一つずつの表示を確認しながら作りましょう。
意図しない挙動や再現させにくいバグがほとんど無くなる
引数とStateの値が同じなら同じ見た目、同じ挙動になるのですから、確認したい引数をStorybookのストーリーとして作っておけば、いつでもパッと再現できてテストできます。
全てのコンポーネントが(ほぼ)純粋関数になっていればそれらをどれだけ複雑に組み合わせたコンポーネントでも(ほぼ)純粋関数なので、「Storybookではちゃんと表示できるが、特定の手順のあとでテキストを書き込むと表示が崩れる」みたいな再現させにくいバグは起こりません。
状態管理フレームワークとStorybookの組み合わせが最高
コンポーネントの引数は外から与えられますが、useState
が返すStateはコンポーネント内部に閉じているため外から与えられません。コンポーネントをまたぐ挙動を実現するのが状態管理フレームワークです。
Storybookと組み合わせると、コンポーネントのStateを外から与えて表示状態を好きなだけコントロールできるため、開発が非常に捗ります。
Redux
Reactの状態管理フレームワークとしてメジャーなのがReduxですね。
Reduxの基本アイディアは、「ページ全体で単一のStateツリー(Store)を持ち、コンポーネントはStoreの一部をStateとして利用する」ということです。これにより、Storeの内容をコントロールすることによりページ内のどのコンポーネントのStateでも自由に設定できます。
また、Redux DevToolsはStoreの変更履歴を保存しているので、ページ全体の状態を巻き戻すという魔法みたいなことができます。
ただ、ページ全体を完全に統制できるというメリットに対して書かなければいけないコード量が非常に多く複雑なので、僕個人の好みとしてはあまり好きではありません。
Recoil
最近React開発元のMetaが新しい状態管理フレームワークRecoilを開発していますね。
(2022年5月現在、Experimental)これは「コンポーネントをまたいだStateを自由にいくつでも持つことができる」というものです。Reduxに比べて非常に少ないコード量でやりたいことが何でもできるので、僕は好きです。
コンポーネント外でatom
というState保持オブジェクトを宣言しておき、コンポーネント中でuseState
ではなくuseRecoilState
を呼ぶ、それだけでコンポーネントをまたいだStateを実現できます。
StorybookのStory上でもatom
の値を設定すれば、そのatom
を参照するコンポーネントの表示と挙動を完全にコントロールできます。
Recoilについては会社のブログに記事を書いているので、よければそちらも。
どういうコンポーネントを書くべきでないか
ここまで、Reactコンポーネントが(ほぼ)純粋関数であれば得られるメリットについて書いてきました。ということは、(ほぼ)純粋関数でない書きかたをすると上記のメリットが得られないということです。
どんなコンポーネントを書くべきでないか、以下に具体的に挙げてみます。
StrictModeでない、ESLintを無視している、使っていない
StrictMode
とESLintのreact/recommended
は必ず使って、エラーや警告が出たら直しましょう。
const ConditionalState: React.FC<{message:string}> = ({message}) => {
const [bold, setBold] = message != "" ? useState(false) : [false, () => {}]
:
:
}
たとえば条件分岐の中でReact hooksを呼ぶと、Stateが引数のようなものとみなせなくなり、コンポーネントが純粋関数のようなものとみなせなくなります。
前述したメリットをたった一つのコンポーネントで破壊できてしまうので、とても危険です。
Stateを変更するためだけのuseEffect
ESLintのエラーで「useState
が返すsetterをコンポーネント関数中で呼ぶな」と言われて苦し紛れに書いたコードがこちらです。(ちょっと例が思いつかず、無理やりな想定になってしまいました。真面目に読まなくていいです。)
// 初回表示時だけ太字で、その後messageが変更されたら細字になる
const MessageViewer: React.FC<{message:string}> = ({message}) => {
const [firstTime, setFirstTime] = useState(true)
const [bold, setBold] = useState(false)
useEffect(() => {
// 初回表示時にフラグをfalseにする
if (isFirstTime) {
setFirstTime(false)
}
}, [])
useEffect(() => {
// 初回表示時以降、messageが変更されたらbold解除
if (isFirstTime) {
setBold(true)
} else {
setBold(false)
}
}, [message])
:
:
return bold ? (<b>{message}</b>) : <>{message}</>
}
コンポーネントの処理コード中でsetterを呼ぶとコンポーネントの再実行がスケジュールされて無限ループになるから禁止されています。
それをESLintで指摘されたからエラーが出ないようにuseEffect
に入れた、みたいなのはたいてい良くないコードになります。
useEffect
でStateを変えるのが最善という例はそれこそビジュアルエフェクトみたいな、時間経過によってアニメーションで表示が変化するとかくらいではないでしょうか。Stateはコンポーネントの表示や挙動を決定するための引数のようなもの、と捉えるようになれば、コンポーネント関数の実行後におまけで実行されるuseEffect
中で呼びたい気持ちが薄れていくと思います。
この例で言えば、そもそもbold
を引数で渡すようにして、bold
にするかどうかの判断はReactコンポーネント初回表示のようないつ変わるかわからない不安定な条件ではなく、APIで変更する前と後とか、ビジネスロジックによって制御されるべきです。
const MessageViewer: React.FC<{message:string, bold: bool}> = ({message, bold}) => {
return bold ? (<b>{message}</b>) : <>{message}</>
}
そんなことを考えつつリファクタリングしたら、実はこのMessageViewer
コンポーネント内のStateは必要ないことがわかりました。
必要ないのにDOMアクセス
ブラウザ上で実行されるJavaScriptなので、コンポーネント処理関数中からDOM要素やブラウザにアクセスすることはいちおう可能です。
こんなこともできちゃいます。
// 一度だけ表示するメッセージ
const OnlyOnceMessage: React.FC<{message:string}> = ({message}) => {
const flag:bool = document.body.getAttribute('data-only-once') === 'true'
useEffect(() => {
document.body.setAttribute('data-only-once', 'true')
}, [])
return bool ? <b>{message}</b> : null
}
useEffect()
で何かしてるところも問題ですが、document.body
にフラグをもたせちゃってることにより、ページ中で1回しか使えないとか、storybookで表示させても表示されたりされなかったりして正しく動作しているのかわからないとか、サーバーサイドレンダリングできないといった問題があります。
これも大きく捉えれば、コンポーネントが純粋関数でなく、ブラウザのDOMへのアクセスという副作用を含んでいることが根本的な問題といえます。
useStateだけで頑張りすぎ
useState
が返すsetterを子孫コンポーネントに渡していって、子コンポーネント上の操作によって親コンポーネントの表示を変化させる、というのは、よくあるパターンかも知れません。
type FlagSetter = ({flag:bool}) => void
const Granpa: React:FC = () => {
const [granpa, setGranpa] = useState(true)
return (<div>
Granpa is {granpa ? 'true' : 'false'}
<Father
granpa={granpa}
setGranpa={setGranpa}
/>
</div>)
}
const Father: React:FC<{granpa:bool, setGranpa:FlagSetter}> = ({granpa, setGranpa}) => {
const [father, setFather] = useState(true)
return (<div>
Father is {father ? 'true' : 'false'}
<Child
granpa={granpa}
setGranpa={setGranpa}
father={father}
setFather={setFather}
/>
</div>)
}
// GranpaとFatherのStateを変更するチェックボックス
const Child: React:FC<{
granpa:bool,
setGranpa:FlagSetter,
father:bool,
setFather:FlagSetter}>
= ({granpa, setGranpa, father, setFather}) => {
const [child, setChild] = useState(true)
// この時点でStateのパターン数は2x2x2=8通り。本当に全部考慮できてる?
:
(略)
:
}
しかし、やりすぎるとコンポーネント同士が密結合になりつつ、Stateのパターン数が掛け算で増えていって考慮漏れが発生しやすくなります。
そもそもStateにパターン数が多く、ロジックが複雑なのであれば、なおのことその複雑なロジックを実現する関数は純粋関数として切り出してテストするべきです。React.useReducerやRedux、Recoilなど、複雑さを切り出す手段はいろいろ用意されているので頑張って勉強しましょう。
-
Elmアーキテクチャ云々のくだりについては省略します ↩︎