setInterval()
をフックに書き替えたuseInterval
の作例
Reactのとくに関数コンポートでsetInterval()
を使うと、やっかいに巻き込まれることが少なくありません。Reactのプログラミングモデルと相性がよくないからです。そこで、面倒なことを考えずに済むように、フック(useInterval
)に書き替えてみましょう(サンプル001)。
サンプル001■useIntervalフックを使ったカウンターの作例
本稿の作例は、Create React AppでReactアプリケーションのひな型をつくりました。TypeScriptも組み込むため、コマンドラインツールでnpx create-react-app プロジェクト名
に、オプション--template typescript
を添えます(「Create React AppでTypeScriptが加わったひな形アプリケーションをつくる」参照)。
setInterval()
のどこが煩わしいのか
setInterval()
でやっかいに巻き込まれるコード例をつぎに示します。1秒ごとにカウントアップするカウンターです。副作用ですので、useEffect
フックに定め、クリーンアップの処理(clearInterval()
)はコールバック関数にして返します。今回の作例では、ひたすらカウントアップをし続けるだけです。つまり、setInterval()
ははじめに1度呼び出せばよいので、useEffect
フックの第2引数、依存配列は空([]
)にしました。
import React, { useEffect, useState } from 'react';
function App() {
const [count, setCount] = useState(0);
const [delay, setDelay] = useState(1000);
useEffect(() => {
const interval = setInterval(() => {
console.log(count); // 出力: 0
setCount(count + 1);
}, delay);
return () => clearInterval(interval);
}, []);
return (
<>
<h1>{count}</h1>
</>
);
}
export default App;
実行すると、カウンターの数値は0から1になったまま変わりません。インターバルの加算が止まったのかとブラウザのコンソールを見ると、変数(count
)の値として0が何度も出力されています(console.log()
)。つまり、処理そのものは繰り返されており、ただもとの数値が0なので、加算した1が設定されているということです。
なぜ、インターバルのカウンター(count
)数値は更新されないのでしょう。それは、はじめに副作用(useEffect
)が実行されたとき、setInterval()
のコールバック関数は状態変数(count
)の値をクロージャに保持してしまい、インターバルのたびに新たな状態変数値を参照し直さないからです。
これを避けるには、useEffect
の第2引数の依存配列に状態変数(count
)を加えることが考えられます。状態変数が変わるたびに、クリーンアップの処理が行われ、新たな変数値にもとづいてsetInterval()
が呼び出されるということです1。
function App() {
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1);
}, delay);
// }, []);
}, [count]);
}
もうひとつ、状態変数の設定関数(setCount
)呼び出しを関数型の更新にする手もあります。引数の関数に渡されるのは、最新の状態変数値です。その値に加算して返せばカウンターは正しくカウントアップされます。
function App() {
useEffect(() => {
const interval = setInterval(() => {
// setCount(count + 1);
setCount((count) => ++count);
}, delay);
}, []);
}
けれど、本稿のお題は、このようなやっかいごとに巻き込まれずに済むインターバルのカスタムフック(useInterval
)を定めることです。順を追って考えてゆきましょう。
setInterval()
の処理をフックuseInterval
に書き替える
インターバルのカスタムフックuseInterval
は、つぎのように副作用(useEffect
)をフック内部に取り込みます。引数はsetInterval()
と同じ、コールバック関数(callback
)とインターバルミリ秒(delay
)です。後者はsetInterval()
に渡すとき、デフォルト値を0にしました。
import { useEffect } from 'react';
const useInterval = (callback: Function, delay?: number) => {
useEffect(() => {
const interval = setInterval(() =>
callback()
, delay || 0);
return () => clearInterval(interval);
}, [callback, delay]);
}
export default useInterval;
フックuseInterval
は、ルートモジュールsrc/App.tsx
から、コンポーネント(App
)内でつぎのように単純に呼び出すだけです。副作用はフックが扱いますから、煩わされることはありません。
// import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import useInterval from './useInterval';
function App() {
/* useEffect(() => {
const interval = setInterval(() => {
console.log('render:', count);
setCount(count + 1);
}, delay);
return () => clearInterval(interval);
}, []); */
useInterval(() => {
console.log('render:', count); // 確認用
setCount(count + 1);
}, delay);
}
useInterval
フックの引数でインターバルの間隔を変える
フックuseInterval
では、インターバルの間隔(delay
)を副作用(useEffect
)の依存配列に加えました。それは、フックを使う側から、間隔が自由に変えられるということです。そこで、ルートモジュールsrc/App.tsx
のコンポーネント(App
)には、つぎのように数値の入力フィールド(<input type="number">
)を加えて、インターバルのミリ秒が設定できるようにしました。
function App() {
const handleDelayChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setDelay(Number(event.target.value));
}
return (
<>
<input type="number" value={delay} onChange={handleDelayChange} />
</>
);
}
書き替えるのはこのルートモジュール(src/App.tsx
)の修正だけです。フックuseInterval
に手は加えません。これだけで、入力フィールドの数値で、インターバルの間隔が変わるようになりました。
インターバル処理を一時停止する
つぎは、フックuseInterval
に新たな機能を加えましょう。インターバルの処理を一時停止することです。フックの第2引数(delay
)をnull
に設定したとき、一時停止とします。といっても書き替えは、つぎのようにフックの第2引数の型づけを改め、条件判定をひとつ加えるだけです。
// const useInterval = (callback: Function, delay?: number) => {
const useInterval = (callback: Function, delay?: number | null) => {
useEffect(() => {
if (delay !== null) {
const interval = setInterval(() =>
callback()
, delay || 0);
}
}, [callback, delay]);
}
ルートモジュール(src/App.tsx
)のコンポーネント(App
)には、チェックボックス(<input type="checkbox">
)を加えて、値は状態変数(isRunning
)に定めました。フックuseInterval
の第2引数をこの変数で切り替えれば、インターバルの処理が一時停止できるのです。
function App() {
const [isRunning, setIsRunning] = useState(true);
useInterval(() => {
// }, delay);
}, isRunning ? delay : null);
const handleIsRunningChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setIsRunning(event.target.checked);
}
return (
<>
<h1>{count}</h1>
<input type="checkbox" checked={isRunning} onChange={handleIsRunningChange} /> Running
<br />
</>
);
}
これで一旦、フックuseInterval
の動きはできました。ルートモジュールsrc/App.tsx
と合わせて、つぎのコード001にそれぞれの記述全体をまとめましょう。
コード001■ useIntervalフックを使ったカウンターサンプル
import React, { useState } from 'react';
import useInterval from './useInterval';
function App() {
const [count, setCount] = useState(0);
const [delay, setDelay] = useState(1000);
const [isRunning, setIsRunning] = useState(true);
useInterval(() => {
console.log('render:', count);
setCount(count + 1);
}, isRunning ? delay : null);
const handleDelayChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setDelay(Number(event.target.value));
}
const handleIsRunningChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setIsRunning(event.target.checked);
}
return (
<>
<h1>{count}</h1>
<input type="checkbox" checked={isRunning} onChange={handleIsRunningChange} /> Running
<br />
<input type="number" value={delay} onChange={handleDelayChange} />
</>
);
}
export default App;
import { useEffect } from 'react';
const useInterval = (callback: Function, delay?: number | null) => {
useEffect(() => {
if (delay !== null) {
const interval = setInterval(() =>
callback()
, delay || 0);
return () => clearInterval(interval);
}
}, [callback, delay]);
}
export default useInterval;
コールバック関数をRefに設定する
フックuseInterval
の副作用の依存配列を、改めて見直します。インターバル間隔(delay
)はよいとして、コールバック(callback
)が変わったからといってsetInterval()
を呼び直さなくてもよいはずです。関数の参照さえ改めれば済みます。
そういうときに用いるのがRefオブジェクトです(「インスタンス変数のようなものはありますか?」参照)。Refオブジェクトのcurrent
プロパティは、レンダーによって改められることがなく、値が変わっても再描画は引き起こしません(「useRef」参照)。
そこで、副作用(useEffect
)をコールバック(callback
)とインターバル間隔(delay
)でつぎのように分けます。コールバックはRefオブジェクト(savedCallback
)のcurrent
プロパティ値の参照を改めるだけです。レンダーはし直しません。インターバル間隔が変わったときに、setInterval()
を呼び出し直して再描画します。
// import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
const useInterval = (callback: Function, delay?: number | null) => {
const savedCallback = useRef<Function>(() => {});
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay !== null) {
const interval = setInterval(() =>
// callback()
savedCallback.current()
, delay || 0);
}
// }, [callback, delay]);
}, [delay]);
}
書き改めたフックuseInterval
の記述を、つぎのコード002に掲げます。実は、このコードは、react-useのuseInterval
をもとにしました。フックがつくられたきっかけは、Dan Abramov氏のblog記事「Making setInterval Declarative with React Hooks」です。実装は、細かいところが少し異なります。さらに、本稿ではコールバック(savedCallback
)に対する副作用の依存(callback
)を加えました(「Should only run effect when callback changes」参照)。実際のコードの動きは、冒頭に掲げたサンプル001のCodeSandbox作例をご参照ください。
コード002■コールバック関数をRefに設定したuseIntervalフック
import { useEffect, useRef } from 'react';
const useInterval = (callback: Function, delay?: number | null) => {
const savedCallback = useRef<Function>(() => {});
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay !== null) {
const interval = setInterval(() =>
savedCallback.current()
, delay || 0);
return () => clearInterval(interval);
}
return undefined;
}, [delay]);
}
export default useInterval;
-
このコード例では、インターバルのたびに変数値がカウントアップされ、クリーンアップのあと
setInterval()
が呼び直されます。ですから、setTimeout()
(クリーンアップはclearTimeout()
)に書き替えても動きは同じです。 ↩