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を初めて触ったのは昨日です。よろしくお願いします。