今回は 10 月下旬ぐらいから React 界隈で少し話題になっている React Hooks API を実験的に自分の作っているアプリに適用してみたのでその紹介をしたいと思います。
React Hooks API とは Facebook が公開している React という UI コンポーネントを作るためのライブラリ に追加予定の新しい API 群で、従来の API 群では状態を保持するコンポーネントを作成するには class を使って実装していたところを単純な function で扱えるようにするものです。
公式のアナウンス および 追加予定の API 一覧は以下で確認できます。
従来の API 群で状態を保持するコンポーネント(カウンター)を作ると以下のようになります。
class Counter extends Component { |
constructor() { |
this.countUp = () => { |
this.setState({ count: this.state.count + 1 }); |
}; |
this.state = { count: 0 }; |
} |
render() { |
return ( |
<> |
{this.state.count} |
<button onClick={this.countUp}>+1</button> |
</> |
); |
} |
} |
これを React Hooks API を使用して記述すると以下のように書くことになります。
function Counter() { |
const [count, setCount] = useState(0); |
const countUp = useCallback(() => setCount(prev => prev + 1), []); |
return ( |
<> |
{count} |
<button onClick={countUp}>+1</button> |
</> |
); |
} |
各処理の説明は端折りますが class を function として定義しなおし、インスタンス変数で扱っていた 状態(state)を useState というユーティリティを使いローカル変数で扱うようになっていることが分かるかと思います。
なおこの記事を書いている現在(12 月初旬)ではまだ策定段階で、リリースまでに API に変更が入る可能性があります。 また公式から既存の class ベースのコンポーネント定義方法を廃止する予定はなく使っていて問題ない旨アナウンスされています。 人柱になりたい方以外は急いで適用する必要はないと思われます。
以前このブログでも紹介した JavaScript 対戦アプリ のクライアント側の実装で React を使用していたため、今回そこに React Hooks API を適用してみました。
コードはTypeScriptで実装しており、アプリ全体の規模としては 5,630 step 程度。 クライアント側は SPA として実装しており 3,310 step 程度となっていました。
Redux 等の状態管理系のライブラリは使っておらず、状態管理は少々のシングルトンのクラスと、コンポーネント上の State で行っています。
修正内容は Pull Request の形で置いてあり、以下で確認できます。
実際に行った修正作業を簡単に説明していきます。
以下のように @next
をつけて、開発中のライブラリに更新/ダウンロードする。
$ npm i react@next react-dom@next |
TypeScript 向けの定義ファイルについてはすでに React Hooks API 対応の定義ファイルが最新のリリースにのっているため、シンプルに最新に落としてくれば OK です。
$ npm i -D @types/react @types/react-dom |
結果 package.json および package-lock.json に差分が出る形になっています。 3285db5
これで React Hooks API を使っていく準備ができました。
あとはひたすら class を使って定義されているコンポーネントを function で定義し直し、 React Hooks API を使うように置き換えていきます。
今回の作業の中では以下のようなパターンで置き換えを行っていきました。
- class を function に置き換える
- Props の interface は使いまわし function の引数 props として利用する
- State の interface および State の初期化処理の実装を元に useState を使う実装に置き換える
(State の interface はほぼほぼ削除) - render メソッドで行っていた処理を一旦そのまま function に移植する
- イベントハンドラなどがメソッドとして定義されていれば function の中で定義するように移植する
(本当は useCallback を使った方が良さそう) - this.props.xxx で参照していた箇所は function 引数の props から参照するように置き換える
- this.state.xxx で参照していた箇所は単純なローカル変数の参照 xxx に置き換える
- componentDidMount および componentWillUnmount を使っていた箇所は useEffect を使うように修正する
致し方なくインスタンスで扱っていた変数がローカル変数で扱えるようになりスッキリします!
- コンポーネント配置時に何かしらのオブジェクトを生成し使いまわしたい場合 useMemo を使うように修正 (例: d39edc5)
- ref を使って DOM を扱っているところは useRef を使うように修正 (例: 29e9cc0)
なお shouldComponentUpdate を使い DOM の再構成のタイミングを制御していたものは useMemo を使う形に置き換える (例: fe5672f) - API アクセス処理の共通化・・・コンポーネントのライフサイクルを加味した共通処理がかける!
今回は api-hooks.ts という共通モジュールを作成 (例: 9d46d3c)
あとは React の基本理念とずれたイレギュラーな実装をしているコンポーネントなどが素直に置き換えられない場面があり、あの手この手で対応しました。
適用していく上で感じたメリットや課題など紹介します。
単純に記号的な実装が少なくなり、本質的な実装のみに集中することでコードが圧縮されたと感じます。
実際にクライアントの Step 数で 3,310 -> 2,967 と 10% 程度の削減ができました!(気づいたリファクタリングもやりながら修正したため少し反則気味ですが 😛 )
Step 数以外でも例えば this.state.xxxx と記述していたところが 単純に xxxx でかけるなど、見た目スッキリし可読性上がっているかと思います。
また、1つの関心事について実装が集まりやすくなりました。 これをすごくわかりやすく示した例が Twitter で紹介されていました が実際に使ってみて、同様のメリットを強く感じました。
今まではコンポーネントのライフサイクルに依存する処理を共通化しようと思うと HOC などの実装パターンで作る必要があり、現状 OSS ライブラリなどでは この HOC を前提にした API を提供していることが多いです。ただし、この HOC を使ったパターンではやりたいことに対し複雑度が高くなってしまっていたように感じます。
これに対し React Hooks API を使うことでこのライフサイクルに依存する処理の共通化が本当に簡単に実現できます。 やったこと の項で記載しましたが API アクセス処理の共通化では コンポーネントのマウント時に通信開始/通信完了時に状態更新/アンマウント時に通信中止 といった一覧の処理を以下のような実装で共通化できました。
// ユーザーの情報を取る共通処理 |
function useUser(account) { |
const [user, setUser] = React.useState(null); |
React.useEffect( |
() => { |
const abortController = new AbortController(); |
const signal = abortController.signal; |
setUser(null); |
User.select({ signal, account }) |
.then(res => setUser(res)) |
.catch(error => console.log(error)); |
return () => abortController.abort(); |
}, |
[account] |
); |
return user; |
} |
// 使う側のコード |
function Hoge(props) { |
const user = useUser(props.account); |
if (!user) { |
return <span>loading..</span>; |
} |
return <span>{user.name}</span>; |
} |
次に複雑に感じたところや実装でハマったポイント紹介します。
React Hooks API は function のトップレベルで使う必要がある(ループや条件分岐の中で使ってはいけない)というルールがあったりします。これは API の In/Out を眺めただけだと気づかない場合もありそうです。
React Hooks API は関数の中で状態を管理するためにそれぞれの状態を一意に特定する必要があるが、引数に名前などを渡すような API にはなっていません。 これを解決するために React Hooks API ではその呼び出し順でインデックスを振り、状態を管理しているようです。 そのためこの呼び出し順が狂うループや条件分岐を避ける必要があり、このルールをちゃんと認識しながら実装を進めないと分かりにくい不具合にぶつかってしまいます。
NG/OK の例は以下のような感じです。
// NG |
if (name) { |
useEffect(() => { |
localStorage.setItem("hoge", name); |
}, []); |
} |
// OK |
useEffect(() => { |
if (name) { |
localStorage.setItem("hoge", name); |
} |
}, []); |
上記のルールは先に記載した共通化した処理でも考慮する必要があるため useXxxxx のような名前付けを行い、処理中に React Hooks API が使われているかどうかがわかるようなメソッド名にしておく必要がありそうです。また lint ルールもすでに出てきており、公式では eslint-plugin-react-hooks が紹介されていました。
class を使ったコンポーネントでは this.state.xxxx のようにインスタンス変数としてその状態を参照していましたが、これが const [name, setName] = React.useState("test"); のようにローカル変数として参照することになります。
これはその function の中で単純に使う上では困らないのですが、イベントハンドラなど function を宣言し始めると途端にやりにくい場面が出てきます。
具体的な例として、時間でカウントアップしていくタイマーのようなものを実装したときを考えます。深く考えずに実装すると以下のようなものになるのではないかと思います。
function CountUpTimer() { |
const [count, setCount] = useState(0); |
function countUp() { |
setCount(count + 1); |
} |
useEffect(() => { |
const id = setInterval(countUp, 1000); |
return () => clearInterval(id); |
}, []); |
return <span>{count}</span>; |
} |
しかし、これは正常に動作しません。 理由としては setInterval に登録された function countUp はローカル変数 count をクロージャとして参照していますが、 setCount による値更新が行われ CountUpTimer 全体が再度実行されても setInterval に登録されている function countUp が参照している値は古いままだからです。
これを解決する方法はいくつかありますが、今回のケースでお手軽な方法は setCount に function を登録する方法です。下記の通り、 function を登録しておくことで、クロージャとして参照している値ではなく、その瞬間の値を使って更新を行うことができます。
setCount(prev => prev + 1); |
この方法も他の state を参照したくなったりすると解決できなかったりで、様々な条件を汲んでいくと class ベースの実装のほうがシンプルなのでは?と感じる場面もありました。
React Hooks API は自身の評価としては基本的に可読性/メンテナンス性も向上が感じられたため、一旦は使っていく方向に倒したいと思っています。取り急ぎは useContext など今回使っていない API もあるため、少し実装を見直しつつ使ってみたいです。
先に課題として述べたとおり class ベースのほうが可読性高く感じられる場面もあるため、共存の方法が今後整理されてくると嬉しいです。
また整理できていないところがパフォーマンスのところで、自分がざっくりと適用した段階だとパフォーマンスが若干低下したようすです。 これは自分の実装がオブジェクトの再生成などを考慮したものになっていないからと思っていますが、どのように実装すれば安価にパフォーマンスが出せるかがまだ整理できていません。 このあたりも今後ノウハウがそろってくると嬉しいです。
以上、自分の作っているアプリに実験的に React Hooks API を適用してみた結果の紹介でした。
明日以降もまだまだ 『CRESCO Advent Calendar 2018』 続いていきます。 ぜひぜひ引き続きお楽しみください。