LoginSignup
21
8

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-10-26

setInterval()をフックに書き替えたuseIntervalの作例

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

サンプル001■useIntervalフックを使ったカウンターの作例

useInterval.png
>> CodeSandboxへ

本稿の作例は、Create React AppでReactアプリケーションのひな型をつくりました。TypeScriptも組み込むため、コマンドラインツールでnpx create-react-app プロジェクト名に、オプション--template typescriptを添えます(「Create React AppでTypeScriptが加わったひな形アプリケーションをつくる」参照)。

setInterval()のどこが煩わしいのか

setInterval()でやっかいに巻き込まれるコード例をつぎに示します。1秒ごとにカウントアップするカウンターです。副作用ですので、useEffectフックに定め、クリーンアップの処理(clearInterval())はコールバック関数にして返します。今回の作例では、ひたすらカウントアップをし続けるだけです。つまり、setInterval()ははじめに1度呼び出せばよいので、useEffectフックの第2引数、依存配列は空([])にしました。

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)の値をクロージャに保持してしまい、インターバルのたびに新たな状態変数値を参照し直さないからです。

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

src/App.tsx
function App() {

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

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

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

}

もうひとつ、状態変数の設定関数(setCount)呼び出しを関数型の更新にする手もあります。引数の関数に渡されるのは、最新の状態変数値です。その値に加算して返せばカウンターは正しくカウントアップされます。

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にしました。

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

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

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

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

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フックを使ったカウンターサンプル

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;
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オブジェクトです(「インスタンス変数のようなものはありますか?」参照)。Refオブジェクトのcurrentプロパティは、レンダーによって改められることがなく、値が変わっても再描画は引き起こしません(「useRef」参照)。

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

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-useuseIntervalをもとにしました。フックがつくられたきっかけは、Dan Abramov氏のblog記事「Making setInterval Declarative with React Hooks」です。実装は、細かいところが少し異なります。さらに、本稿ではコールバック(savedCallback)に対する副作用の依存(callback)を加えました(「Should only run effect when callback changes」参照)。実際のコードの動きは、冒頭に掲げたサンプル001のCodeSandbox作例をご参照ください。

コード002■コールバック関数をRefに設定したuseIntervalフック

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;

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

21
8
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
8