React + TypeScript: setInterval()をReactのプログラミングモデルに合わせてフックに書き替える ー useInterval

# `setInterval()`をフックに書き替えた`useInterval`の作例
Reactのとくに関数コンポートで`setInterval()`を使うと、やっかいに巻き込まれることが少なくありません。Reactのプログラミングモデルと相性がよくないからです。そこで、面倒なことを考えずに済むように、フック(`useInterval`)に書き替えてみましょう(サンプル001)。

#### サンプル001■useIntervalフックを使ったカウンターの作例
[![useInterval.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128949/76d470a8-cd0f-e19f-4445-917771a4dbdc.png)
\>> CodeSandboxへ](https://codesandbox.io/s/react-typescript-useinterval-hook-h6fyh?file=/src/useInterval.ts)


本稿の作例は、[Create React App](https://ja.reactjs.org/docs/create-a-new-react-app.html#create-react-app)でReactアプリケーションのひな型をつくりました。TypeScriptも組み込むため、コマンドラインツールで`npx create-react-app プロジェクト名`に、オプション`--template typescript`を添えます(「[Create React AppでTypeScriptが加わったひな形アプリケーションをつくる](https://www.fumiononaka.com/Business/html5/FN2006001.html#sec01)」参照)。


# `setInterval()`のどこが煩わしいのか
`setInterval()`でやっかいに巻き込まれるコード例をつぎに示します。1秒ごとにカウントアップするカウンターです。副作用ですので、[`useEffect`](https://ja.reactjs.org/docs/hooks-effect.html)フックに定め、[クリーンアップ](https://ja.reactjs.org/docs/hooks-effect.html#effects-with-cleanup)の処理(`clearInterval()`)はコールバック関数にして返します。今回の作例では、ひたすらカウントアップをし続けるだけです。つまり、`setInterval()`ははじめに1度呼び出せばよいので、`useEffect`フックの第2引数、[依存配列](https://ja.reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects)は空(`[]`)にしました。

```react:src/App.tsx
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`)の値を[クロージャ](https://developer.mozilla.org/docs/Web/JavaScript/Closures)に保持してしまい、インターバルのたびに新たな状態変数値を参照し直さないからです。

これを避けるには、`useEffect`の第2引数の依存配列に状態変数(`count`)を加えることが考えられます。状態変数が変わるたびに、クリーンアップの処理が行われ、新たな変数値にもとづいて`setInterval()`が呼び出されるということです[^1]。

```react:src/App.tsx
function App() {

	useEffect(() => {
		const interval = setInterval(() => {

			setCount(count + 1);
		}, delay);

	// }, []);
	}, [count]);

}
```

[^1]: このコード例では、インターバルのたびに変数値がカウントアップされ、クリーンアップのあと`setInterval()`が呼び直されます。ですから、`setTimeout()`(クリーンアップは`clearTimeout()`)に書き替えても動きは同じです。

もうひとつ、状態変数の設定関数(`setCount`)呼び出しを[関数型の更新](https://ja.reactjs.org/docs/hooks-reference.html#functional-updates)にする手もあります。引数の関数に渡されるのは、最新の状態変数値です。その値に加算して返せばカウンターは正しくカウントアップされます。

```react:src/App.tsx
function App() {

	useEffect(() => {
		const interval = setInterval(() => {

			// setCount(count + 1);
			setCount((count) => ++count);
		}, delay);

	}, []);

}
```

けれど、本稿のお題は、このようなやっかいごとに巻き込まれずに済むインターバルのカスタムフック(`useInterval`)を定めることです。順を追って考えてゆきましょう。


# `setInterval()`の処理をフック`useInterval`に書き替える
インターバルのカスタムフック`useInterval`は、つぎのように副作用(`useEffect`)をフック内部に取り込みます。引数は`setInterval()`と同じ、コールバック関数(`callback`)とインターバルミリ秒(`delay`)です。後者は`setInterval()`に渡すとき、デフォルト値を0にしました。

```react:src/useInterval.ts
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`)内でつぎのように単純に呼び出すだけです。副作用はフックが扱いますから、煩わされることはありません。

```react:src/App.tsx
// 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">`)を加えて、インターバルのミリ秒が設定できるようにしました。

```react:src/App.tsx
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引数の型づけを改め、条件判定をひとつ加えるだけです。

```react:src/useInterval.ts
// 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引数をこの変数で切り替えれば、インターバルの処理が一時停止できるのです。

```react:src/App.tsx
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フックを使ったカウンターサンプル
```react:src/App.tsx
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;
```

```react:src/useInterval.ts
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オブジェクトです(「[インスタンス変数のようなものはありますか？](https://ja.reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables)」参照)。Refオブジェクトの`current`プロパティは、レンダーによって改められることがなく、値が変わっても再描画は引き起こしません(「[useRef](https://ja.reactjs.org/docs/hooks-reference.html#useref)」参照)。

そこで、副作用(`useEffect`)をコールバック(`callback`)とインターバル間隔(`delay`)でつぎのように分けます。コールバックはRefオブジェクト(`savedCallback`)の`current`プロパティ値の参照を改めるだけです。レンダーはし直しません。インターバル間隔が変わったときに、`setInterval()`を呼び出し直して再描画します。

```react:src/useInterval.ts
// 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](https://github.com/streamich/react-use#--------------------react-use------------------)の[`useInterval`](https://github.com/streamich/react-use/blob/master/docs/useInterval.md#useinterval)をもとにしました。フックがつくられたきっかけは、Dan Abramov氏のblog記事「[Making setInterval Declarative with React Hooks](https://overreacted.io/making-setinterval-declarative-with-react-hooks/)」です。[実装](https://github.com/streamich/react-use/blob/master/src/useInterval.ts)は、細かいところが少し異なります。さらに、本稿ではコールバック(`savedCallback`)に対する副作用の依存(`callback`)を加えました(「[Should only run effect when callback changes](https://github.com/streamich/react-use/pull/1564)」参照)。実際のコードの動きは、冒頭に掲げたサンプル001のCodeSandbox作例をご参照ください。

#### コード002■コールバック関数をRefに設定したuseIntervalフック
```react:src/useInterval.ts
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;
```