Reactでタイマーを作る|setIntervalの使い方と注意点

ReactでsetIntervalを使ったタイマーの作り方を解説。メモリリーク対策、useEffectでのクリーンアップ、カスタムフックの作成方法まで詳しく説明します。

Learning Next 運営
74 分で読めます

みなさん、Reactでタイマーやカウントダウンを作りたいと思ったことはありませんか?

「setIntervalを使ったけど上手く動かない」 「タイマーが止まらない」 「メモリリークが心配」

このような問題に直面している方も多いでしょう。

この記事では、ReactでsetIntervalを使ったタイマーの正しい作り方について詳しく解説します。 基本的な使い方から、メモリリーク対策、実践的なタイマーアプリケーションまで、実際のコード例とともに学んでいきましょう。

setIntervalの基本と問題点

みなさんはsetIntervalという関数をご存知ですか?

簡単に言うと、一定の間隔で処理を繰り返し実行する便利な機能です。 でも、Reactで使う時は注意が必要なんです。

基本的なsetIntervalの使い方

まずは基本的な使い方を見てみましょう。

// 基本的なsetInterval(React外での使用例)
let count = 0;

const timer = setInterval(() => {
    count++;
    console.log(`カウント: ${count}`);
    
    // 10回でタイマーを停止
    if (count >= 10) {
        clearInterval(timer);
        console.log('タイマー終了');
    }
}, 1000); // 1秒間隔

この例では、1秒ごとにカウントが増えていきます。 10回実行したら自動的に停止するようになっています。

Reactでの間違った使い方

では、これをReactで使うとどうなるでしょうか?

import React, { useState } from 'react';

// ❌ 間違った例:メモリリークやバグの原因
function BrokenTimer() {
    const [count, setCount] = useState(0);
    const [isRunning, setIsRunning] = useState(false);
    
    // ❌ この書き方は問題だらけ
    const startTimer = () => {
        setInterval(() => {
            setCount(count + 1); // 古い count の値を参照
        }, 1000);
        setIsRunning(true);
    };
    
    const stopTimer = () => {
        // タイマーIDを保持していないので停止できない
        setIsRunning(false);
    };
    
    return (
        <div>
            <h1>壊れたタイマー</h1>
            <p>カウント: {count}</p>
            <button onClick={startTimer} disabled={isRunning}>
                開始
            </button>
            <button onClick={stopTimer} disabled={!isRunning}>
                停止
            </button>
        </div>
    );
}

一見正しそうに見えますが、実は大きな問題があります。

なぜ上記のコードが問題なのか?

このコードには3つの重大な問題があります。

// 問題1: クロージャの古い値参照
const [count, setCount] = useState(0);

setInterval(() => {
    setCount(count + 1); // count は常に初期値の 0 を参照
}, 1000);
// 結果: 1, 1, 1, 1... (期待値: 1, 2, 3, 4...)

setIntervalの中で使っているcountは、最初に設定された値(0)のままなんです。 だから何度実行しても1にしかなりません。

// 問題2: タイマーIDの管理不備
let timerId; // スコープの問題
const startTimer = () => {
    timerId = setInterval(() => {
        // 処理
    }, 1000);
};
// コンポーネントが再レンダリングされると timerId が失われる

タイマーのIDを適切に管理できていないため、停止ができません。

// 問題3: メモリリーク
// clearInterval が呼ばれずにコンポーネントがアンマウントされる
// → タイマーが永続的に実行され続ける

コンポーネントが削除されても、タイマーは動き続けてしまいます。 これがメモリリークの原因です。

Reactでの基本的な注意点

Reactでタイマーを使う時は、以下の点に注意しましょう。

  • Stateの古い値を参照する問題を避ける
  • タイマーIDの適切な管理をする
  • コンポーネントアンマウント時のクリーンアップを忘れない
  • 再レンダリング時の重複タイマー作成を防ぐ
  • メモリリークの防止を徹底する

でも大丈夫です! これらの問題は、正しい方法を知れば簡単に解決できます。

useEffectでの正しいタイマー実装

useEffectを使って、正しくタイマーを実装する方法を学びましょう。

基本的なタイマーの実装

まずは、シンプルなタイマーから作ってみます。

import React, { useState, useEffect } from 'react';

function BasicTimer() {
    const [count, setCount] = useState(0);
    const [isRunning, setIsRunning] = useState(false);
    
    useEffect(() => {
        let intervalId;
        
        if (isRunning) {
            intervalId = setInterval(() => {
                // ✅ 関数型更新で最新の state を取得
                setCount(prevCount => prevCount + 1);
            }, 1000);
        }
        
        // ✅ クリーンアップ関数でタイマーを停止
        return () => {
            if (intervalId) {
                clearInterval(intervalId);
            }
        };
    }, [isRunning]); // isRunning が変更された時に実行
    
    const startTimer = () => {
        setIsRunning(true);
    };
    
    const stopTimer = () => {
        setIsRunning(false);
    };
    
    const resetTimer = () => {
        setIsRunning(false);
        setCount(0);
    };
    
    return (
        <div>
            <h1>基本的なタイマー</h1>
            <p>カウント: {count}</p>
            <div>
                <button onClick={startTimer} disabled={isRunning}>
                    開始
                </button>
                <button onClick={stopTimer} disabled={!isRunning}>
                    停止
                </button>
                <button onClick={resetTimer}>
                    リセット
                </button>
            </div>
            <p>状態: {isRunning ? '実行中' : '停止中'}</p>
        </div>
    );
}

この実装のポイントを解説しますね。

setCount(prevCount => prevCount + 1)の部分がとても重要です。 これにより、常に最新の値を取得できます。

return () => { ... }の部分はクリーンアップ関数と呼ばれます。 useEffectが再実行される前や、コンポーネントが削除される時に実行されます。

ストップウォッチの実装

次は、もう少し高機能なストップウォッチを作ってみましょう。

function Stopwatch() {
    const [time, setTime] = useState(0); // ミリ秒で管理
    const [isRunning, setIsRunning] = useState(false);
    
    useEffect(() => {
        let intervalId;
        
        if (isRunning) {
            intervalId = setInterval(() => {
                setTime(prevTime => prevTime + 10); // 10ms間隔で更新
            }, 10);
        }
        
        return () => {
            if (intervalId) {
                clearInterval(intervalId);
            }
        };
    }, [isRunning]);
    
    // 時間を表示用にフォーマット
    const formatTime = (milliseconds) => {
        const totalSeconds = Math.floor(milliseconds / 1000);
        const minutes = Math.floor(totalSeconds / 60);
        const seconds = totalSeconds % 60;
        const ms = Math.floor((milliseconds % 1000) / 10);
        
        return `${minutes.toString().padStart(2, '0')}:${seconds
            .toString()
            .padStart(2, '0')}.${ms.toString().padStart(2, '0')}`;
    };
    
    const start = () => setIsRunning(true);
    const stop = () => setIsRunning(false);
    const reset = () => {
        setIsRunning(false);
        setTime(0);
    };
    
    return (
        <div>
            <h1>ストップウォッチ</h1>
            <div style={{ fontSize: '2rem', fontFamily: 'monospace' }}>
                {formatTime(time)}
            </div>
            <div>
                <button onClick={start} disabled={isRunning}>
                    開始
                </button>
                <button onClick={stop} disabled={!isRunning}>
                    停止
                </button>
                <button onClick={reset}>
                    リセット
                </button>
            </div>
        </div>
    );
}

この例では、10ミリ秒間隔で更新しています。 より正確な時間表示ができますね。

formatTime関数で、ミリ秒を「分:秒.ミリ秒」の形式に変換しています。 数字の前に0をつけるpadStartメソッドも活用しています。

カウントダウンタイマーの実装

次は、時間が減っていくカウントダウンタイマーを作ってみましょう。

function CountdownTimer() {
    const [initialTime, setInitialTime] = useState(60); // 初期時間(秒)
    const [timeLeft, setTimeLeft] = useState(60);
    const [isRunning, setIsRunning] = useState(false);
    const [isFinished, setIsFinished] = useState(false);
    
    useEffect(() => {
        let intervalId;
        
        if (isRunning && timeLeft > 0) {
            intervalId = setInterval(() => {
                setTimeLeft(prevTime => {
                    const newTime = prevTime - 1;
                    
                    // タイマー終了時の処理
                    if (newTime <= 0) {
                        setIsRunning(false);
                        setIsFinished(true);
                        
                        // 終了通知(ブラウザ通知)
                        if ('Notification' in window && Notification.permission === 'granted') {
                            new Notification('タイマー終了!', {
                                body: '設定した時間が経過しました。',
                                icon: '/timer-icon.png'
                            });
                        }
                        
                        return 0;
                    }
                    
                    return newTime;
                });
            }, 1000);
        }
        
        return () => {
            if (intervalId) {
                clearInterval(intervalId);
            }
        };
    }, [isRunning, timeLeft]);
    
    // 時間フォーマット関数
    const formatTime = (seconds) => {
        const hours = Math.floor(seconds / 3600);
        const minutes = Math.floor((seconds % 3600) / 60);
        const secs = seconds % 60;
        
        if (hours > 0) {
            return `${hours}:${minutes.toString().padStart(2, '0')}:${secs
                .toString()
                .padStart(2, '0')}`;
        }
        return `${minutes}:${secs.toString().padStart(2, '0')}`;
    };
    
    const start = () => {
        if (timeLeft > 0) {
            setIsRunning(true);
            setIsFinished(false);
        }
    };
    
    const pause = () => {
        setIsRunning(false);
    };
    
    const reset = () => {
        setIsRunning(false);
        setTimeLeft(initialTime);
        setIsFinished(false);
    };
    
    const setTimer = (minutes) => {
        const seconds = minutes * 60;
        setInitialTime(seconds);
        setTimeLeft(seconds);
        setIsRunning(false);
        setIsFinished(false);
    };
    
    // 進行状況の計算
    const progress = ((initialTime - timeLeft) / initialTime) * 100;
    
    return (
        <div>
            <h1>カウントダウンタイマー</h1>
            
            {/* 時間設定ボタン */}
            <div>
                <button onClick={() => setTimer(1)}>1分</button>
                <button onClick={() => setTimer(5)}>5分</button>
                <button onClick={() => setTimer(10)}>10分</button>
                <button onClick={() => setTimer(25)}>25分</button>
            </div>
            
            {/* 時間表示 */}
            <div 
                style={{ 
                    fontSize: '3rem', 
                    fontFamily: 'monospace',
                    color: timeLeft <= 10 ? 'red' : 'black' // 残り10秒で色変更
                }}
            >
                {formatTime(timeLeft)}
            </div>
            
            {/* 進行状況バー */}
            <div style={{ 
                width: '300px', 
                height: '20px', 
                border: '1px solid #ccc',
                margin: '20px 0'
            }}>
                <div style={{
                    width: `${progress}%`,
                    height: '100%',
                    backgroundColor: timeLeft <= 10 ? 'red' : '#4CAF50',
                    transition: 'all 0.3s ease'
                }} />
            </div>
            
            {/* コントロールボタン */}
            <div>
                <button onClick={start} disabled={isRunning || timeLeft === 0}>
                    開始
                </button>
                <button onClick={pause} disabled={!isRunning}>
                    一時停止
                </button>
                <button onClick={reset}>
                    リセット
                </button>
            </div>
            
            {/* 終了メッセージ */}
            {isFinished && (
                <div style={{ 
                    padding: '20px', 
                    backgroundColor: '#f0f8ff', 
                    border: '2px solid #007bff',
                    borderRadius: '8px',
                    margin: '20px 0'
                }}>
                    <h2>🎉 タイマー終了!</h2>
                    <p>お疲れさまでした!</p>
                </div>
            )}
        </div>
    );
}

この例では、残り時間に応じて色が変わったり、ブラウザ通知も使っています。 進行状況バーで視覚的にも分かりやすくしました。

複数タイマーの管理

最後に、複数のタイマーを同時に管理する例も見てみましょう。

function MultipleTimers() {
    const [timers, setTimers] = useState([]);
    const [nextId, setNextId] = useState(1);
    
    // 新しいタイマーを追加
    const addTimer = (name, duration) => {
        const newTimer = {
            id: nextId,
            name: name || `タイマー ${nextId}`,
            duration: duration || 60,
            timeLeft: duration || 60,
            isRunning: false,
            isFinished: false
        };
        
        setTimers(prev => [...prev, newTimer]);
        setNextId(prev => prev + 1);
    };
    
    // タイマーを削除
    const removeTimer = (id) => {
        setTimers(prev => prev.filter(timer => timer.id !== id));
    };
    
    // タイマーの状態を更新
    const updateTimer = (id, updates) => {
        setTimers(prev => prev.map(timer =>
            timer.id === id ? { ...timer, ...updates } : timer
        ));
    };
    
    // 各タイマーのuseEffect
    useEffect(() => {
        const intervals = {};
        
        timers.forEach(timer => {
            if (timer.isRunning && timer.timeLeft > 0) {
                intervals[timer.id] = setInterval(() => {
                    setTimers(prev => prev.map(t => {
                        if (t.id === timer.id) {
                            const newTimeLeft = t.timeLeft - 1;
                            if (newTimeLeft <= 0) {
                                return {
                                    ...t,
                                    timeLeft: 0,
                                    isRunning: false,
                                    isFinished: true
                                };
                            }
                            return { ...t, timeLeft: newTimeLeft };
                        }
                        return t;
                    }));
                }, 1000);
            }
        });
        
        return () => {
            Object.values(intervals).forEach(intervalId => {
                clearInterval(intervalId);
            });
        };
    }, [timers]);
    
    const formatTime = (seconds) => {
        const minutes = Math.floor(seconds / 60);
        const secs = seconds % 60;
        return `${minutes}:${secs.toString().padStart(2, '0')}`;
    };
    
    return (
        <div>
            <h1>複数タイマー管理</h1>
            
            <div>
                <button onClick={() => addTimer('作業タイマー', 25 * 60)}>
                    25分タイマー追加
                </button>
                <button onClick={() => addTimer('休憩タイマー', 5 * 60)}>
                    5分タイマー追加
                </button>
            </div>
            
            <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '20px', marginTop: '20px' }}>
                {timers.map(timer => (
                    <TimerCard
                        key={timer.id}
                        timer={timer}
                        onUpdate={updateTimer}
                        onRemove={removeTimer}
                        formatTime={formatTime}
                    />
                ))}
            </div>
            
            {timers.length === 0 && (
                <p style={{ textAlign: 'center', color: '#666', marginTop: '50px' }}>
                    タイマーを追加してください
                </p>
            )}
        </div>
    );
}

function TimerCard({ timer, onUpdate, onRemove, formatTime }) {
    const progress = ((timer.duration - timer.timeLeft) / timer.duration) * 100;
    
    return (
        <div style={{
            border: '1px solid #ddd',
            borderRadius: '8px',
            padding: '20px',
            backgroundColor: timer.isFinished ? '#e8f5e8' : 'white'
        }}>
            <h3>{timer.name}</h3>
            
            <div style={{
                fontSize: '2rem',
                fontFamily: 'monospace',
                color: timer.timeLeft <= 10 ? 'red' : 'black',
                margin: '10px 0'
            }}>
                {formatTime(timer.timeLeft)}
            </div>
            
            <div style={{
                width: '100%',
                height: '8px',
                backgroundColor: '#eee',
                borderRadius: '4px',
                margin: '10px 0'
            }}>
                <div style={{
                    width: `${progress}%`,
                    height: '100%',
                    backgroundColor: timer.isFinished ? '#4CAF50' : '#2196F3',
                    borderRadius: '4px',
                    transition: 'width 0.3s ease'
                }} />
            </div>
            
            <div>
                <button
                    onClick={() => onUpdate(timer.id, { isRunning: true })}
                    disabled={timer.isRunning || timer.timeLeft === 0}
                >
                    開始
                </button>
                <button
                    onClick={() => onUpdate(timer.id, { isRunning: false })}
                    disabled={!timer.isRunning}
                >
                    停止
                </button>
                <button onClick={() => onRemove(timer.id)}>
                    削除
                </button>
            </div>
        </div>
    );
}

複数のタイマーを管理する場合は、配列でタイマーを管理します。 各タイマーにユニークなIDを付けて、個別に操作できるようにしています。

カスタムフックでのタイマー実装

タイマーの機能を再利用しやすくするため、カスタムフックとして実装してみましょう。

基本的なタイマーフック

まずは、シンプルなタイマーフックから作ってみます。

import { useState, useEffect, useRef } from 'react';

function useTimer(initialTime = 0, interval = 1000) {
    const [time, setTime] = useState(initialTime);
    const [isRunning, setIsRunning] = useState(false);
    const intervalRef = useRef(null);
    
    useEffect(() => {
        if (isRunning) {
            intervalRef.current = setInterval(() => {
                setTime(prevTime => prevTime + interval);
            }, interval);
        } else {
            if (intervalRef.current) {
                clearInterval(intervalRef.current);
                intervalRef.current = null;
            }
        }
        
        return () => {
            if (intervalRef.current) {
                clearInterval(intervalRef.current);
            }
        };
    }, [isRunning, interval]);
    
    const start = () => setIsRunning(true);
    const stop = () => setIsRunning(false);
    const reset = () => {
        setTime(initialTime);
        setIsRunning(false);
    };
    
    return {
        time,
        isRunning,
        start,
        stop,
        reset,
        setTime
    };
}

// 使用例
function TimerWithHook() {
    const timer = useTimer(0, 1000); // 0秒から開始、1秒間隔
    
    const formatTime = (milliseconds) => {
        const seconds = Math.floor(milliseconds / 1000);
        const minutes = Math.floor(seconds / 60);
        const remainingSeconds = seconds % 60;
        
        return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
    };
    
    return (
        <div>
            <h1>カスタムフックタイマー</h1>
            <div style={{ fontSize: '2rem' }}>
                {formatTime(timer.time)}
            </div>
            <div>
                <button onClick={timer.start} disabled={timer.isRunning}>
                    開始
                </button>
                <button onClick={timer.stop} disabled={!timer.isRunning}>
                    停止
                </button>
                <button onClick={timer.reset}>
                    リセット
                </button>
            </div>
        </div>
    );
}

このカスタムフックを使うと、タイマー機能を簡単に再利用できます。 useRefを使ってタイマーIDを管理しているのがポイントです。

高機能なカウントダウンフック

次は、もう少し高機能なカウントダウンフックを作ってみましょう。

function useCountdown(initialTime, options = {}) {
    const {
        interval = 1000,
        onComplete = () => {},
        onTick = () => {},
        autoStart = false
    } = options;
    
    const [timeLeft, setTimeLeft] = useState(initialTime);
    const [isRunning, setIsRunning] = useState(autoStart);
    const [isCompleted, setIsCompleted] = useState(false);
    const intervalRef = useRef(null);
    const initialTimeRef = useRef(initialTime);
    
    useEffect(() => {
        initialTimeRef.current = initialTime;
        setTimeLeft(initialTime);
        setIsCompleted(false);
    }, [initialTime]);
    
    useEffect(() => {
        if (isRunning && timeLeft > 0) {
            intervalRef.current = setInterval(() => {
                setTimeLeft(prevTime => {
                    const newTime = prevTime - interval;
                    
                    // onTick コールバック実行
                    onTick(newTime);
                    
                    if (newTime <= 0) {
                        setIsRunning(false);
                        setIsCompleted(true);
                        onComplete();
                        return 0;
                    }
                    
                    return newTime;
                });
            }, interval);
        } else {
            if (intervalRef.current) {
                clearInterval(intervalRef.current);
                intervalRef.current = null;
            }
        }
        
        return () => {
            if (intervalRef.current) {
                clearInterval(intervalRef.current);
            }
        };
    }, [isRunning, timeLeft, interval, onComplete, onTick]);
    
    const start = () => {
        if (timeLeft > 0) {
            setIsRunning(true);
            setIsCompleted(false);
        }
    };
    
    const pause = () => setIsRunning(false);
    
    const reset = () => {
        setTimeLeft(initialTimeRef.current);
        setIsRunning(false);
        setIsCompleted(false);
    };
    
    const addTime = (amount) => {
        setTimeLeft(prev => Math.max(0, prev + amount));
    };
    
    // 進行状況の計算
    const progress = timeLeft > 0 ? 
        ((initialTimeRef.current - timeLeft) / initialTimeRef.current) * 100 : 100;
    
    return {
        timeLeft,
        isRunning,
        isCompleted,
        progress,
        start,
        pause,
        reset,
        addTime
    };
}

// 使用例
function AdvancedCountdownTimer() {
    const countdown = useCountdown(60000, { // 60秒
        interval: 1000,
        onComplete: () => {
            alert('タイマー完了!');
        },
        onTick: (timeLeft) => {
            if (timeLeft <= 10000 && timeLeft % 1000 === 0) {
                console.log(`残り${timeLeft / 1000}秒`);
            }
        }
    });
    
    const formatTime = (milliseconds) => {
        const totalSeconds = Math.floor(milliseconds / 1000);
        const minutes = Math.floor(totalSeconds / 60);
        const seconds = totalSeconds % 60;
        
        return `${minutes}:${seconds.toString().padStart(2, '0')}`;
    };
    
    return (
        <div>
            <h1>高機能カウントダウン</h1>
            
            <div style={{ fontSize: '3rem', fontFamily: 'monospace' }}>
                {formatTime(countdown.timeLeft)}
            </div>
            
            <div style={{ 
                width: '300px', 
                height: '20px', 
                backgroundColor: '#eee',
                borderRadius: '10px',
                overflow: 'hidden',
                margin: '20px 0'
            }}>
                <div style={{
                    width: `${countdown.progress}%`,
                    height: '100%',
                    backgroundColor: countdown.timeLeft <= 10000 ? '#ff4444' : '#4CAF50',
                    transition: 'all 0.3s ease'
                }} />
            </div>
            
            <div>
                <button onClick={countdown.start} disabled={countdown.isRunning}>
                    開始
                </button>
                <button onClick={countdown.pause} disabled={!countdown.isRunning}>
                    一時停止
                </button>
                <button onClick={countdown.reset}>
                    リセット
                </button>
                <button onClick={() => countdown.addTime(30000)}>
                    +30秒
                </button>
            </div>
            
            {countdown.isCompleted && (
                <div style={{
                    marginTop: '20px',
                    padding: '20px',
                    backgroundColor: '#d4edda',
                    border: '1px solid #c3e6cb',
                    borderRadius: '8px'
                }}>
                    🎉 カウントダウン完了!
                </div>
            )}
        </div>
    );
}

このフックでは、onCompleteonTickのコールバック関数を使えます。 タイマーの完了時や毎秒実行時に、独自の処理を追加できて便利ですね。

ポモドーロタイマーフック

最後に、ポモドーロテクニック用のタイマーフックも作ってみましょう。

function usePomodoroTimer() {
    const [mode, setMode] = useState('work'); // 'work', 'shortBreak', 'longBreak'
    const [cycle, setCycle] = useState(1);
    const [completedPomodoros, setCompletedPomodoros] = useState(0);
    
    const durations = {
        work: 25 * 60 * 1000,      // 25分
        shortBreak: 5 * 60 * 1000,  // 5分
        longBreak: 15 * 60 * 1000   // 15分
    };
    
    const countdown = useCountdown(durations[mode], {
        onComplete: () => {
            handleModeComplete();
        }
    });
    
    const handleModeComplete = () => {
        if (mode === 'work') {
            setCompletedPomodoros(prev => prev + 1);
            
            // 4回目の作業完了後は長い休憩
            if (cycle === 4) {
                setMode('longBreak');
                setCycle(1);
            } else {
                setMode('shortBreak');
                setCycle(prev => prev + 1);
            }
        } else {
            setMode('work');
        }
    };
    
    const startNewMode = (newMode) => {
        setMode(newMode);
        countdown.reset();
    };
    
    const resetAll = () => {
        setMode('work');
        setCycle(1);
        setCompletedPomodoros(0);
        countdown.reset();
    };
    
    return {
        ...countdown,
        mode,
        cycle,
        completedPomodoros,
        startNewMode,
        resetAll,
        durations
    };
}

// 使用例
function PomodoroTimer() {
    const pomodoro = usePomodoroTimer();
    
    const formatTime = (milliseconds) => {
        const totalSeconds = Math.floor(milliseconds / 1000);
        const minutes = Math.floor(totalSeconds / 60);
        const seconds = totalSeconds % 60;
        
        return `${minutes}:${seconds.toString().padStart(2, '0')}`;
    };
    
    const getModeLabel = (mode) => {
        switch (mode) {
            case 'work': return '作業時間';
            case 'shortBreak': return '短い休憩';
            case 'longBreak': return '長い休憩';
            default: return '';
        }
    };
    
    const getModeColor = (mode) => {
        switch (mode) {
            case 'work': return '#d32f2f';
            case 'shortBreak': return '#388e3c';
            case 'longBreak': return '#1976d2';
            default: return '#666';
        }
    };
    
    return (
        <div style={{ textAlign: 'center', padding: '20px' }}>
            <h1>ポモドーロタイマー</h1>
            
            <div style={{
                backgroundColor: getModeColor(pomodoro.mode),
                color: 'white',
                padding: '40px',
                borderRadius: '20px',
                margin: '20px 0'
            }}>
                <h2>{getModeLabel(pomodoro.mode)}</h2>
                <div style={{ fontSize: '4rem', fontFamily: 'monospace' }}>
                    {formatTime(pomodoro.timeLeft)}
                </div>
                <div>サイクル: {pomodoro.cycle}/4</div>
            </div>
            
            <div style={{ margin: '20px 0' }}>
                <button onClick={pomodoro.start} disabled={pomodoro.isRunning}>
                    開始
                </button>
                <button onClick={pomodoro.pause} disabled={!pomodoro.isRunning}>
                    一時停止
                </button>
                <button onClick={pomodoro.reset}>
                    リセット
                </button>
            </div>
            
            <div style={{
                backgroundColor: '#f5f5f5',
                padding: '20px',
                borderRadius: '10px',
                margin: '20px 0'
            }}>
                <h3>統計</h3>
                <p>完了したポモドーロ: {pomodoro.completedPomodoros}</p>
                <p>現在のサイクル: {pomodoro.cycle}/4</p>
                <p>現在のモード: {getModeLabel(pomodoro.mode)}</p>
            </div>
        </div>
    );
}

ポモドーロタイマーでは、作業と休憩を自動で切り替えます。 4回の作業サイクル後は長い休憩になるような仕組みも作っています。

メモリリーク対策と最適化

タイマーを使う時の重要な注意点と最適化方法について説明します。

メモリリークの原因と対策

メモリリークは、プログラムが不要になったメモリを解放しない問題です。 タイマーでは特に注意が必要なんです。

// ❌ メモリリークの原因となるパターン
function BadTimerComponent() {
    const [count, setCount] = useState(0);
    
    useEffect(() => {
        // 問題1: クリーンアップ関数がない
        setInterval(() => {
            setCount(prev => prev + 1);
        }, 1000);
        
        // この useEffect にはクリーンアップ関数がないため、
        // コンポーネントがアンマウントされてもタイマーが残る
    }, []);
    
    return <div>カウント: {count}</div>;
}

このコードの問題は、クリーンアップ関数がないことです。 コンポーネントが削除されても、タイマーは動き続けてしまいます。

// ✅ 正しいメモリリーク対策
function GoodTimerComponent() {
    const [count, setCount] = useState(0);
    const [isActive, setIsActive] = useState(true);
    
    useEffect(() => {
        let intervalId;
        
        if (isActive) {
            intervalId = setInterval(() => {
                setCount(prev => prev + 1);
            }, 1000);
        }
        
        // ✅ 必ずクリーンアップ関数を定義
        return () => {
            if (intervalId) {
                clearInterval(intervalId);
                console.log('タイマーをクリーンアップしました');
            }
        };
    }, [isActive]);
    
    return (
        <div>
            <div>カウント: {count}</div>
            <button onClick={() => setIsActive(!isActive)}>
                {isActive ? '停止' : '開始'}
            </button>
        </div>
    );
}

必ずクリーンアップ関数を書くことで、メモリリークを防げます。

useRef を使ったタイマーID管理

タイマーIDをより安全に管理する方法を見てみましょう。

function OptimizedTimer() {
    const [count, setCount] = useState(0);
    const [isRunning, setIsRunning] = useState(false);
    const intervalRef = useRef(null);
    const startTimeRef = useRef(null);
    
    // ✅ useRef でタイマーIDを安全に管理
    const startTimer = () => {
        if (!isRunning) {
            startTimeRef.current = Date.now() - count * 1000;
            intervalRef.current = setInterval(() => {
                const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000);
                setCount(elapsed);
            }, 100); // より正確な時間表示のため100msで更新
            setIsRunning(true);
        }
    };
    
    const stopTimer = () => {
        if (intervalRef.current) {
            clearInterval(intervalRef.current);
            intervalRef.current = null;
        }
        setIsRunning(false);
    };
    
    const resetTimer = () => {
        stopTimer();
        setCount(0);
        startTimeRef.current = null;
    };
    
    // ✅ コンポーネントアンマウント時のクリーンアップ
    useEffect(() => {
        return () => {
            if (intervalRef.current) {
                clearInterval(intervalRef.current);
            }
        };
    }, []);
    
    return (
        <div>
            <h1>最適化されたタイマー</h1>
            <div style={{ fontSize: '2rem' }}>
                {Math.floor(count / 60)}:{(count % 60).toString().padStart(2, '0')}
            </div>
            <button onClick={startTimer} disabled={isRunning}>
                開始
            </button>
            <button onClick={stopTimer} disabled={!isRunning}>
                停止
            </button>
            <button onClick={resetTimer}>
                リセット
            </button>
        </div>
    );
}

useRefを使うことで、タイマーIDが再レンダリング時に失われることを防げます。 また、実際の経過時間を計算することで、より正確なタイマーになります。

パフォーマンス最適化

タイマーアプリのパフォーマンスを向上させる方法も確認しましょう。

// ✅ React.memo を使った最適化
const TimerDisplay = React.memo(function TimerDisplay({ time, isRunning }) {
    console.log('TimerDisplay がレンダリングされました');
    
    const formatTime = (seconds) => {
        const minutes = Math.floor(seconds / 60);
        const secs = seconds % 60;
        return `${minutes}:${secs.toString().padStart(2, '0')}`;
    };
    
    return (
        <div style={{
            fontSize: '3rem',
            fontFamily: 'monospace',
            color: isRunning ? '#4CAF50' : '#666'
        }}>
            {formatTime(time)}
        </div>
    );
});

function OptimizedTimerApp() {
    const [time, setTime] = useState(0);
    const [isRunning, setIsRunning] = useState(false);
    const [otherState, setOtherState] = useState(0);
    
    useEffect(() => {
        let intervalId;
        
        if (isRunning) {
            intervalId = setInterval(() => {
                setTime(prevTime => prevTime + 1);
            }, 1000);
        }
        
        return () => {
            if (intervalId) {
                clearInterval(intervalId);
            }
        };
    }, [isRunning]);
    
    // ✅ useCallback でイベントハンドラーを最適化
    const handleStart = useCallback(() => {
        setIsRunning(true);
    }, []);
    
    const handleStop = useCallback(() => {
        setIsRunning(false);
    }, []);
    
    const handleReset = useCallback(() => {
        setTime(0);
        setIsRunning(false);
    }, []);
    
    return (
        <div>
            <h1>最適化されたタイマーアプリ</h1>
            
            {/* TimerDisplay は time と isRunning が変わった時のみ再レンダリング */}
            <TimerDisplay time={time} isRunning={isRunning} />
            
            <div>
                <button onClick={handleStart} disabled={isRunning}>
                    開始
                </button>
                <button onClick={handleStop} disabled={!isRunning}>
                    停止
                </button>
                <button onClick={handleReset}>
                    リセット
                </button>
            </div>
            
            {/* 他の状態変更はタイマー表示に影響しない */}
            <div>
                <p>その他の状態: {otherState}</p>
                <button onClick={() => setOtherState(prev => prev + 1)}>
                    その他の状態を更新
                </button>
            </div>
        </div>
    );
}

React.memouseCallbackを使うことで、不要な再レンダリングを防げます。 これにより、アプリケーション全体のパフォーマンスが向上します。

バックグラウンドでの動作対策

ブラウザがバックグラウンドになった時の対策も重要です。

function BackgroundAwareTimer() {
    const [time, setTime] = useState(0);
    const [isRunning, setIsRunning] = useState(false);
    const startTimeRef = useRef(null);
    
    useEffect(() => {
        let intervalId;
        
        if (isRunning) {
            startTimeRef.current = Date.now() - time * 1000;
            
            intervalId = setInterval(() => {
                const now = Date.now();
                const elapsed = Math.floor((now - startTimeRef.current) / 1000);
                setTime(elapsed);
            }, 1000);
            
            // ✅ ページの可視性変更を監視
            const handleVisibilityChange = () => {
                if (!document.hidden && isRunning) {
                    // ページが再び表示された時、正確な時間を再計算
                    const now = Date.now();
                    const elapsed = Math.floor((now - startTimeRef.current) / 1000);
                    setTime(elapsed);
                }
            };
            
            document.addEventListener('visibilitychange', handleVisibilityChange);
            
            // ✅ フォーカス復帰時の時間補正
            const handleFocus = () => {
                if (isRunning && startTimeRef.current) {
                    const now = Date.now();
                    const elapsed = Math.floor((now - startTimeRef.current) / 1000);
                    setTime(elapsed);
                }
            };
            
            window.addEventListener('focus', handleFocus);
            
            return () => {
                clearInterval(intervalId);
                document.removeEventListener('visibilitychange', handleVisibilityChange);
                window.removeEventListener('focus', handleFocus);
            };
        }
        
        return () => {
            if (intervalId) {
                clearInterval(intervalId);
            }
        };
    }, [isRunning]);
    
    const start = () => {
        setIsRunning(true);
    };
    
    const stop = () => {
        setIsRunning(false);
    };
    
    const reset = () => {
        setTime(0);
        setIsRunning(false);
        startTimeRef.current = null;
    };
    
    const formatTime = (seconds) => {
        const hours = Math.floor(seconds / 3600);
        const minutes = Math.floor((seconds % 3600) / 60);
        const secs = seconds % 60;
        
        if (hours > 0) {
            return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
        }
        return `${minutes}:${secs.toString().padStart(2, '0')}`;
    };
    
    return (
        <div>
            <h1>バックグラウンド対応タイマー</h1>
            <div style={{ fontSize: '2rem', fontFamily: 'monospace' }}>
                {formatTime(time)}
            </div>
            <div>
                <button onClick={start} disabled={isRunning}>
                    開始
                </button>
                <button onClick={stop} disabled={!isRunning}>
                    停止
                </button>
                <button onClick={reset}>
                    リセット
                </button>
            </div>
            <p style={{ fontSize: '0.9rem', color: '#666' }}>
                このタイマーはブラウザがバックグラウンドになっても正確に動作します
            </p>
        </div>
    );
}

この実装では、ページの可視性変更やフォーカス復帰を監視しています。 ブラウザがバックグラウンドになっても、正確な時間を保てるようになります。

実践的なタイマーアプリケーション

実際のプロジェクトで使えるタイマーアプリの例を紹介します。

フィットネスタイマー

運動の時に使えるタイマーを作ってみましょう。

function FitnessTimer() {
    const [workoutPlan, setWorkoutPlan] = useState([
        { name: 'ウォームアップ', duration: 5 * 60, type: 'warmup' },
        { name: '高強度運動', duration: 30, type: 'work' },
        { name: '休憩', duration: 10, type: 'rest' },
        { name: '高強度運動', duration: 30, type: 'work' },
        { name: '休憩', duration: 10, type: 'rest' },
        { name: '高強度運動', duration: 30, type: 'work' },
        { name: 'クールダウン', duration: 3 * 60, type: 'cooldown' }
    ]);
    
    const [currentIndex, setCurrentIndex] = useState(0);
    const [isRunning, setIsRunning] = useState(false);
    const [timeLeft, setTimeLeft] = useState(workoutPlan[0]?.duration || 0);
    const [totalElapsed, setTotalElapsed] = useState(0);
    
    const currentExercise = workoutPlan[currentIndex];
    const totalDuration = workoutPlan.reduce((sum, exercise) => sum + exercise.duration, 0);
    const overallProgress = (totalElapsed / totalDuration) * 100;
    
    useEffect(() => {
        let intervalId;
        
        if (isRunning && timeLeft > 0) {
            intervalId = setInterval(() => {
                setTimeLeft(prev => {
                    const newTime = prev - 1;
                    setTotalElapsed(prevTotal => prevTotal + 1);
                    
                    if (newTime <= 0) {
                        // 次のエクササイズに進む
                        if (currentIndex < workoutPlan.length - 1) {
                            setCurrentIndex(prevIndex => prevIndex + 1);
                            return workoutPlan[currentIndex + 1].duration;
                        } else {
                            // ワークアウト完了
                            setIsRunning(false);
                            alert('ワークアウト完了!お疲れさまでした!');
                            return 0;
                        }
                    }
                    
                    return newTime;
                });
            }, 1000);
        }
        
        return () => {
            if (intervalId) {
                clearInterval(intervalId);
            }
        };
    }, [isRunning, timeLeft, currentIndex, workoutPlan]);
    
    const start = () => setIsRunning(true);
    const pause = () => setIsRunning(false);
    
    const reset = () => {
        setIsRunning(false);
        setCurrentIndex(0);
        setTimeLeft(workoutPlan[0]?.duration || 0);
        setTotalElapsed(0);
    };
    
    const formatTime = (seconds) => {
        const minutes = Math.floor(seconds / 60);
        const secs = seconds % 60;
        return `${minutes}:${secs.toString().padStart(2, '0')}`;
    };
    
    const getExerciseColor = (type) => {
        switch (type) {
            case 'warmup': return '#ff9800';
            case 'work': return '#f44336';
            case 'rest': return '#4caf50';
            case 'cooldown': return '#2196f3';
            default: return '#666';
        }
    };
    
    return (
        <div style={{ padding: '20px', maxWidth: '500px', margin: '0 auto' }}>
            <h1>フィットネスタイマー</h1>
            
            {/* 現在のエクササイズ */}
            <div style={{
                backgroundColor: getExerciseColor(currentExercise?.type),
                color: 'white',
                padding: '30px',
                borderRadius: '15px',
                textAlign: 'center',
                margin: '20px 0'
            }}>
                <h2>{currentExercise?.name}</h2>
                <div style={{ fontSize: '3rem', fontFamily: 'monospace' }}>
                    {formatTime(timeLeft)}
                </div>
                <div>
                    {currentIndex + 1} / {workoutPlan.length}
                </div>
            </div>
            
            {/* 全体の進行状況 */}
            <div style={{ margin: '20px 0' }}>
                <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '5px' }}>
                    <span>全体の進行状況</span>
                    <span>{Math.round(overallProgress)}%</span>
                </div>
                <div style={{
                    width: '100%',
                    height: '10px',
                    backgroundColor: '#eee',
                    borderRadius: '5px',
                    overflow: 'hidden'
                }}>
                    <div style={{
                        width: `${overallProgress}%`,
                        height: '100%',
                        backgroundColor: '#4caf50',
                        transition: 'width 0.3s ease'
                    }} />
                </div>
            </div>
            
            {/* コントロールボタン */}
            <div style={{ textAlign: 'center', margin: '20px 0' }}>
                <button onClick={start} disabled={isRunning} style={{ margin: '0 5px' }}>
                    開始
                </button>
                <button onClick={pause} disabled={!isRunning} style={{ margin: '0 5px' }}>
                    一時停止
                </button>
                <button onClick={reset} style={{ margin: '0 5px' }}>
                    リセット
                </button>
            </div>
        </div>
    );
}

このフィットネスタイマーでは、ワークアウトプランに従って自動的に次のエクササイズに移ります。 運動の種類によって色も変わるので、視覚的にも分かりやすいですね。

料理タイマー

料理の時に使える、複数のタイマーを同時に管理できるアプリも作ってみました。

function CookingTimer() {
    const [timers, setTimers] = useState([]);
    const [nextId, setNextId] = useState(1);
    const [presets] = useState([
        { name: 'ゆで卵(半熟)', duration: 6 * 60 },
        { name: 'ゆで卵(固ゆで)', duration: 10 * 60 },
        { name: 'パスタ', duration: 8 * 60 },
        { name: 'ステーキ(レア)', duration: 3 * 60 },
        { name: 'ステーキ(ミディアム)', duration: 5 * 60 }
    ]);
    
    const addTimer = (name, duration) => {
        const newTimer = {
            id: nextId,
            name,
            duration,
            timeLeft: duration,
            isRunning: false,
            isCompleted: false,
            createdAt: new Date()
        };
        
        setTimers(prev => [...prev, newTimer]);
        setNextId(prev => prev + 1);
    };
    
    const removeTimer = (id) => {
        setTimers(prev => prev.filter(timer => timer.id !== id));
    };
    
    const updateTimer = (id, updates) => {
        setTimers(prev => prev.map(timer =>
            timer.id === id ? { ...timer, ...updates } : timer
        ));
    };
    
    // タイマーの実行ロジック
    useEffect(() => {
        const intervals = {};
        
        timers.forEach(timer => {
            if (timer.isRunning && timer.timeLeft > 0) {
                intervals[timer.id] = setInterval(() => {
                    setTimers(prev => prev.map(t => {
                        if (t.id === timer.id) {
                            const newTimeLeft = t.timeLeft - 1;
                            if (newTimeLeft <= 0) {
                                // タイマー完了時の通知
                                if ('Notification' in window && Notification.permission === 'granted') {
                                    new Notification(`${t.name} 完了!`, {
                                        body: '料理の時間です!'
                                    });
                                }
                                
                                return {
                                    ...t,
                                    timeLeft: 0,
                                    isRunning: false,
                                    isCompleted: true
                                };
                            }
                            return { ...t, timeLeft: newTimeLeft };
                        }
                        return t;
                    }));
                }, 1000);
            }
        });
        
        return () => {
            Object.values(intervals).forEach(intervalId => {
                clearInterval(intervalId);
            });
        };
    }, [timers]);
    
    const formatTime = (seconds) => {
        const minutes = Math.floor(seconds / 60);
        const secs = seconds % 60;
        return `${minutes}:${secs.toString().padStart(2, '0')}`;
    };
    
    return (
        <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
            <h1>🍳 料理タイマー</h1>
            
            {/* プリセットタイマー */}
            <div style={{ marginBottom: '30px' }}>
                <h2>よく使うタイマー</h2>
                <div style={{
                    display: 'grid',
                    gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
                    gap: '10px'
                }}>
                    {presets.map((preset, index) => (
                        <button
                            key={index}
                            onClick={() => addTimer(preset.name, preset.duration)}
                            style={{
                                padding: '15px',
                                borderRadius: '8px',
                                border: '1px solid #ddd',
                                backgroundColor: '#f8f9fa',
                                cursor: 'pointer'
                            }}
                        >
                            <div style={{ fontWeight: 'bold' }}>{preset.name}</div>
                            <div style={{ fontSize: '0.9rem', color: '#666' }}>
                                {formatTime(preset.duration)}
                            </div>
                        </button>
                    ))}
                </div>
            </div>
            
            {/* アクティブなタイマー一覧 */}
            <div style={{ marginTop: '30px' }}>
                <h2>アクティブなタイマー ({timers.length})</h2>
                
                {timers.length === 0 ? (
                    <p style={{ textAlign: 'center', color: '#666', margin: '50px 0' }}>
                        タイマーがありません。上から追加してください。
                    </p>
                ) : (
                    <div style={{
                        display: 'grid',
                        gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
                        gap: '20px'
                    }}>
                        {timers.map(timer => (
                            <CookingTimerCard
                                key={timer.id}
                                timer={timer}
                                onUpdate={updateTimer}
                                onRemove={removeTimer}
                                formatTime={formatTime}
                            />
                        ))}
                    </div>
                )}
            </div>
        </div>
    );
}

function CookingTimerCard({ timer, onUpdate, onRemove, formatTime }) {
    const progress = ((timer.duration - timer.timeLeft) / timer.duration) * 100;
    const isWarning = timer.timeLeft <= 60 && timer.timeLeft > 0; // 残り1分以下
    
    return (
        <div style={{
            border: `2px solid ${timer.isCompleted ? '#4caf50' : isWarning ? '#ff9800' : '#ddd'}`,
            borderRadius: '12px',
            padding: '20px',
            backgroundColor: timer.isCompleted ? '#e8f5e8' : 'white',
            position: 'relative'
        }}>
            {/* 料理名 */}
            <h3 style={{ margin: '0 0 10px 0' }}>{timer.name}</h3>
            
            {/* 時間表示 */}
            <div style={{
                fontSize: '2.5rem',
                fontFamily: 'monospace',
                color: timer.isCompleted ? '#4caf50' : isWarning ? '#ff9800' : 'black',
                textAlign: 'center',
                margin: '15px 0'
            }}>
                {formatTime(timer.timeLeft)}
            </div>
            
            {/* 進行状況バー */}
            <div style={{
                width: '100%',
                height: '8px',
                backgroundColor: '#eee',
                borderRadius: '4px',
                margin: '15px 0',
                overflow: 'hidden'
            }}>
                <div style={{
                    width: `${progress}%`,
                    height: '100%',
                    backgroundColor: timer.isCompleted ? '#4caf50' : isWarning ? '#ff9800' : '#2196f3',
                    transition: 'all 0.3s ease'
                }} />
            </div>
            
            {/* コントロールボタン */}
            <div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
                <button
                    onClick={() => onUpdate(timer.id, { isRunning: true })}
                    disabled={timer.isRunning || timer.timeLeft === 0}
                    style={{ flex: 1 }}
                >
                    開始
                </button>
                <button
                    onClick={() => onUpdate(timer.id, { isRunning: false })}
                    disabled={!timer.isRunning}
                    style={{ flex: 1 }}
                >
                    停止
                </button>
                <button onClick={() => onRemove(timer.id)} style={{ flex: 1 }}>
                    削除
                </button>
            </div>
            
            {/* 状態表示 */}
            <div style={{ textAlign: 'center', marginTop: '10px', fontSize: '0.9rem', color: '#666' }}>
                {timer.isCompleted ? '🎉 完成!' :
                 timer.isRunning ? '🔥 調理中...' :
                 '⏸️ 停止中'}
            </div>
            
            {/* 完了メッセージ */}
            {timer.isCompleted && (
                <div style={{
                    position: 'absolute',
                    top: '10px',
                    right: '10px',
                    backgroundColor: '#4caf50',
                    color: 'white',
                    padding: '5px 10px',
                    borderRadius: '15px',
                    fontSize: '0.8rem',
                    fontWeight: 'bold'
                }}>
                    完了
                </div>
            )}
        </div>
    );
}

この料理タイマーでは、複数の料理を同時に管理できます。 プリセットボタンで簡単にタイマーを追加できるのも便利ですね。

まとめ

ReactでのsetInterval使用方法とタイマー実装について詳しく解説しました。

主要なポイント

useEffectでの管理が重要です。 setIntervalはuseEffect内で使用し、必ずクリーンアップしましょう。

関数型更新を使うことで、古い値の参照問題を解決できます。 setCount(count + 1)ではなくsetCount(prev => prev + 1)を使いましょう。

メモリリーク対策は必須です。 クリーンアップ関数でclearIntervalを必ず実行しましょう。

重要な注意点

  • タイマーIDをuseRefで管理する
  • コンポーネントアンマウント時の適切なクリーンアップ
  • バックグラウンド動作時の時間補正
  • 通知機能やユーザー体験の向上

正しいsetIntervalの使い方を理解することで、安全で高性能なタイマー機能を実装できます。 メモリリークやパフォーマンス問題を避けながら、ユーザーにとって使いやすいタイマーアプリケーションを作成しましょう。

ぜひ実際のプロジェクトでこれらの技術を活用してみてください。

関連記事