Reactを利用している方は、何日か前、React 16.xのロードマップが発表されたことは記憶に新しいかと思います。
それによれば、少し前に発表されて以来界隈に旋風を巻き起こしているReact Hooksが含まれる16.7は、~Q1 2019 とありますね。(少し前はWinter 2018〜2019と書いてあった気がするのですが、もう少し明確な表記に変わりました。)2019年はReact Hooksの年になりますから(多分)、ぜひ今年のうちにReact Hooksを使いこなせるようになりたいものです。
「React Hooksとは何か」とか「React Hooksを触ってみた」みたいな記事は他にもいい記事がありますから、そのような内容はこの記事ではさらっと流します。代わりにこの記事では、React Hooksの本質はCustom Hooks(カスタムフック)であるということを皆さんにお伝えしたいと思います。作って理解するというのはカスタムフックを作って理解するということでした。
この記事はReact.js Advent Calendar 2018の2日目の記事です。
いくつかサンプルコードが登場しますが、コードはこのリポジトリにも置いてあります。npm run buildでブラウザで動作を確かめることが可能です。
React Hooksの基本の復習
さらっと流すとは言いましたが、React Hooksはまだまだ目新しいテーマですから(今出ているのはアルファ版ですしね)、基本的な内容を復習しておきます。Hooksはいくつかの種類がありますが、その中でも特に頻出で重要なのはuseStateとuseEffectです。この2種類について例を挙げておきます。
useStateの例
以下の例のUseStateTestコンポーネントは、ボタンを1つ表示してボタンを押すたびに数字が1つ増えるという例です。
import * as React from 'react';
const { useState } = React;
export const UseStateTest = () => {
// countというステートを利用(初期値は0)
const [count, setCount] = useState(0);
// ボタンが押されたときに呼ぶ関数
const callback = () => setCount(count + 1);
return (
<section>
<h1>useState() test</h1>
<p>
<button type="button" onClick={callback}>
{count}
</button>
</p>
</section>
);
};
useStateを呼び出すことでステートを宣言しています。useStateは現在のステートが入った変数とステートを変更するための関数のペアを返します。ステートが変更されると、当然コンポーネントは再描画されます。
この例では、count変数がステートであり、ボタンが押されたときにステートを変更しています。直感的には、クラスコンポーネントでいうthis.state.countを宣言しているみたいなイメージです。
useEffectの例
useEffectはコンポーネントがマウントされたまたは再描画されたときに呼び出される関数を指定します。ライフサイクルでいうcomponentDidMountとcomponentDidUpdateを合体させたもので、従来は両方に書かないといけなかったようなものをまとめることができて便利ですね。ということで、次はuseEffectを使ってみた例です。
import * as React from 'react';
const { useState, useEffect } = React;
export const UseEffectTest = () => {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(setCount.bind(null, count + 1), 1000);
});
return (
<section>
<h1>useEffect() test</h1>
<p>{count}</p>
</section>
);
};
このコンポーネントは、数値が1つ表示されて、1秒ごとに数値が1ずつ増えていくというものです。useEffectではsetTimeoutを呼び出す関数が登録されています。数値を変化させる関係上前述のuseStateも併用しています。
useEffectの関数はまずマウントされたら呼び出されるので、1秒後にステートを変化させて数値を1増やします。そうするとコンポーネントが再描画されます。それをきっかけに再びuseEffectの関数が発動して1秒後にまた数値を1増やします。このサイクルが繰り返されて数値が増えていきます。
コンポーネントがアンマウントされたときの処理とか外部から再描画されたときの処理を省略していますがまあ例なのでそこはご愛嬌です。
ここで一番のポイントは、何気なくuseStateが一緒に使われているという点です。useEffectだけ使う例も作れないことはないでしょうが、大した意味のある例にはいまいちならなそうです。ここで言いたいことは、フックは組み合わせて使うものであるということです。もしあなたがフックの1つ1つを見て「なんだか使いにくい」とか「componentDidUpdateのほうが便利だなあ」と思ったなら、それはフックの使い方を間違えています。フックは組み合わせて使うものなのです。
ということで、次はフックを組み合わせるとはどういうことか、例を通して説明します。
フックを組み合わせる例
useEffectはコンポーネントがマウントされたときとコンポーネントが再描画されたときに関数が呼び出されるものでした。では、初回は別に処理する必要がないけど再描画時には処理したいときはどうするのでしょうか。つまり、ライフサイクルメソッドでいうcomponentDidUpdate相当のことがしたい場合です。
これを行うには、「今が初回の描画かどうか」ということを自分で変数で管理します。ただ、この変数というのは、前回行なわれた描画のときにセットされた値を次の描画で参照できる必要がありますから、関数内で宣言する普通の変数ではいけません。このような場合はuseRefフックを使います。refというのはDOMノードを生で操作したいときとかに使うあのrefですが、React Hooksにおいては単なる変数として便利に使うことができます。
実際にやってみるとこんな感じになります。
import * as React from 'react';
const { useState, useEffect, useRef } = React;
export const UseOnUpdateTest = () => {
const [count, setCount] = useState(0);
// useEffectの呼び出しが初回かどうかをuseRefで作った変数で管理
const isFirstRef = useRef(true);
// 前回のステートも覚えておく
const lastStateRef = useRef();
useEffect(() => {
// これが初回の呼び出しなら処理をスキップ
if (isFirstRef.current) {
isFirstRef.current = false;
} else {
console.log('再描画されました', lastStateRef.current, count);
}
// 次回使用するために前回のステートを更新
lastStateRef.current = count;
});
return (
<section>
<h1>再描画時のみ処理を行う例</h1>
<p>{count}</p>
<p>
<input type="button" value="+1" onClick={() => setCount(count + 1)} />
</p>
</section>
);
};
ポイントは、useRefで2つの変数isFirstRefとlastStateRefを作っている点です。useRefを使って作ったrefは毎回の描画で同じオブジェクトとなります。そのcurrentプロパティに好きな値をセットしてよいことになっており、これを利用して描画間で変数を共有できます。isFirstRefの中身はこれが初回の描画かどうかを表すフラグであり、lastStateRefは前回のステートを覚えておく変数です。
useEffectではまずisFirstRefの中身をチェックすることで、これが初回の描画かどうか判断します。初回だったときは初回フラグをオフにして終了です。初回でなかったときは本命の処理(ここではconsole.log)を行います。
どうでしょうか。「componentDidUpdateに比べて長すぎる! こんなの使い物にならん!」と思いましたか? そう思った方は、ここからがこの記事の本番ですからもう少し辛抱してください。
ここでいよいよ、この記事の登場であるカスタムフックの登場です。カスタムフックは、要するに「フックの呼び出しをひとまとめにして抜き出した関数」のことです。具体例として、今回書いた処理を抜き出してuseOnUpdateカスタムフックを作ってみました。
import * as React from 'react';
const { useState, useEffect, useRef } = React;
/**
* useOnUpdate: コンポーネントが再描画されたときに前回のステートとともに
* コールバック関数を呼び出す
*/
function useOnUpdate(currentState, callback) {
// useEffectの呼び出しが初回かどうかをuseRefで作った変数で管理
const isFirstRef = useRef(true);
// 前回のステートも変数で管理
const lastStateRef = useRef();
useEffect(() => {
// これが初回の呼び出しならコールバック呼び出しをスキップ
if (isFirstRef.current) {
isFirstRef.current = false;
} else {
callback(lastStateRef.current);
}
// 次回使用するために前回のステートを更新
lastStateRef.current = currentState;
});
}
export const UseOnUpdateTest = () => {
const [count, setCount] = useState(0);
// useOnUpdate カスタムHookを使用
useOnUpdate(count, lastCount => {
console.log('再描画されました', lastCount, count);
});
return (
<section>
<h1>useOnUpdateフックのテスト</h1>
<p>{count}</p>
<p>
<input type="button" value="+1" onClick={() => setCount(count + 1)} />
</p>
</section>
);
};
上のコードをよく読むと、3つのHooks(useRefが2つとuseEffect)を呼び出す部分が別の関数useOnUpdateに抜き出されただけであることが分かると思います。するとあら不思議、コンポーネントの中に残ったのはuseOnUpdateの呼び出しだけです。このように書かれていれば、なんとなくuseOnUpdateがuseEffectが進化した何かのように見えます。処理を関数に抜き出しただけで、まるで新しいフックを作ったかのようです。これがカスタムフックです。「React Hooksとか言われても難しくてよく分からないから自分でフックを作ったりとかしないよ」と思っている方は大間違いです。共通のロジックや処理を関数として分離するという皆さんが普段から当たり前のようにやっていること、それがカスタムフックなのです。
React Hooksで提供されている機能がなんだか不便に見えるという方もいるかもしれませんが、それはある意味では当然のことです。なぜなら、フックは粒度の小さい基本機能が提供されているのであり、複雑なロジックはカスタムフックとして自分で作る(もしくは既存のそういうライブラリを探す)ものだからです。実際、React Hooksはそのような「ロジックを関数(カスタムフック)へと抜き出す」という行為がやりやすいように設計されています。フックの呼び出しという宣言的な手段で状態や副作用を扱うロジックが書けるようになったことは、旧来のAPI(処理がcomponentDidMountやcomponentDidUpdateやrender内などに分散する)に比べて大きな進歩といえます。
カスタムフックの例その2
ではもうひとつカスタムフックを作ってみましょう。少し前にuseEffectの例として作った以下の例を思い出してください。
import * as React from 'react';
const { useState, useEffect } = React;
export const UseEffectTest = () => {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(setCount.bind(null, count + 1), 1000);
});
return (
<section>
<h1>useEffect() test</h1>
<p>{count}</p>
</section>
);
};
実はこれもフックが2つ使われているので、抜き出してカスタムフックを作ることができます。するとこんな感じになります。
import * as React from 'react';
const { useState, useEffect } = React;
/**
* useTimer: 指定された時間ごとにアップデートされるタイマーの値を返す
* @param {number} initialValue タイマーの初期値
* @param {number} interval タイマーがインクリメントされる間隔
* @returns {number} 現在のタイマーの値
*/
function useTimer(initialValue, interval) {
const [count, setCount] = useState(initialValue);
useEffect(() => {
setTimeout(setCount.bind(null, count + 1), interval);
});
return count;
}
export const UseTimerTest = () => {
// タイマーを登録
const timerValue = useTimer(0, 1000);
return (
<section>
<h1>useTimer() test</h1>
<p>{timerValue}</p>
</section>
);
};
今回も見て分かる通り、ほとんどロジックをuseTimerという関数(カスタムフック)に抜き出しただけです。すると、すごく不思議なカスタムフックができましたね。useTimerを呼び出すと返り値は現在のタイマーの値だけですが、ちゃんと1秒ごとにコンポーネントの再描画が起こって値がアップデートされるのです。
これはuseTimerが内部でuseStateを利用しておりステートを内包しているからであることは言うまでもありません。このように、Custom Hooksを用いることで、明示的にuseStateを使わなくても内部にステートを持ったロジックを作ることができます。これは、クラスコンポーネントで明示的にthis.stateを管理しなければいけなかったのに比べると柔軟にロジックが書けるように進化しているといえます。
裏を返せば、ステートがどうなっていていつコンポーネントが再描画されるのか分かりにくいという問題が発生することにはなりますから、カスタムフックを作る際にはしっかりとしたドキュメンテーションが必要ということになると思います。
外部と接続する例
カスタムフックは、基本的には今まで説明した例のように基本的なフックを組み合わせて作るロジックであり、状態管理はuseStateとuseRefで行っていました。これはつまり、状態管理をReactに任せていることになります。
しかし、自分で(すなわちReactの外部で)状態管理をしたい場合もあるかと思います。その場合、ユーザー向けのインターフェースとしてカスタムフックを用意することになります。今回はそのような例も用意してみました。ただ、多少長いのでこの記事にコードを載せることはしません。前述のリポジトリのこのファイル及びそれが参照しているhook.jsをご参照ください。
ここで作ったuseRealtimeTimerカスタムフックは、requestAnimationFrameで60fpsでループを回してコールバックを呼び出してくれるカスタムフックです。複数のコンポーネントが同時にuseRealtimeTimerを使っても複数ループが回ることはなく、1つのループで全部のコールバックの処理が行なわれます。
これを実現するためにuseRealtimeTimerは独自の状態管理をしています。これにより、複数コンポーネントからのフック呼び出しに対して横断的な状態管理が可能になっています。ここでの状態管理は適当にモジュールレベルのローカル変数で管理しています。
まとめ
以上です。この記事で伝えたかったことは、React Hooksはカスタムフックを作って理解すべしということです。useStateとかuseEffectをちょっと使ってみて満足しているようではいけません。
記事内で少し触れた通り、React Hooksは宣言的にロジックを書くことができ、それがカスタムフックの作りやすさに繋がっています。一方で、宣言的に書けるのはその裏でReactの内部アーキテクチャと密接に結びついているからであり、それゆえにやや非直感的に感じるかもしれません。個人的にはAlgebraic Effectsとかは好きなので結構直感的に感じるのですが。
とにかく、React HooksやCustom Hooksの概念を正しく理解することができれば、より自由にロジックを書くことができるReact Hooksの力で開発が効率化すること間違い無しです(※ 個人の感想です)。ぜひCustom Hooksを書いてReact Hooksを使いこなしましょう。
この記事が少しでもその助けになれば幸いです。ちなみに、React Hooksを初めて触ったのは昨日です。よろしくお願いします。