2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React】タイマーアプリを作ってuseEffectのロジックに慣れよう

Posted at

前提
TypeScriptの利用部分は簡単な説明にとどめます🙇
Nextで作りましたが、react18以降であれば同じコードで動作すると思います。

完成品

画面録画 2025-03-27 224749.gif

機能要件

  • 「スタート」ボタンを押すと、1秒ごとに秒数が増える
  • 「ストップ」ボタンを押すと、カウントが停止する
  • 「リセット」ボタンを押すと、秒数が0に戻る&停止状態になる

非機能要件

  • ボタンの状態に応じて「スタート/ストップ」が切り替わる
  • カウントはsetIntervalで実行されるが、重複せず常に1つのインスタンスのみで動作
  • ステートや副作用の管理はuseEffectuseStateを活用

まずはコードの全体像

import { NextPage } from 'next';
import { useEffect, useState } from 'react';
import Button from '@/components/common/parts/Button';

const Page: NextPage = () => {
  const [seconds, setTime] = useState(0);
  const [isActive, setIsActive] = useState(false);

  useEffect(() => {
    // 初期状態は undefined、setInterval の戻り値で上書きする
    let interval: NodeJS.Timeout | undefined = undefined;

    if (isActive) {
      // スタート時:1秒ごとにカウントアップ
      interval = setInterval(() => {
        setTime((prev) => prev + 1);
      }, 1000);
    } else if (!isActive && seconds !== 0) {
      // 停止時:前回の setInterval を停止
      clearInterval(interval);
    }

    // クリーンアップ関数:再実行やアンマウント時に前の interval を停止
    return () => clearInterval(interval);
  }, [isActive, seconds]);

  const handleClickToggle = () => {
    setIsActive((prev) => !prev);
  };

  const handleClickReset = () => {
    setTime(0);
    setIsActive(false);
  };

  return (
    <div className="mx-auto max-w-4xl">
      <div className="mt-10 flex justify-center gap-x-8">
        <div>
          <h3>時間: {seconds}</h3>
        </div>
        <div className="ml-10 flex gap-x-8">
          <Button
            variant="text"
            label={isActive ? 'ストップ' : 'スタート'}
            onClick={handleClickToggle}
          />
          <Button variant="text" label="リセット" onClick={handleClickReset} />
        </div>
      </div>
    </div>
  );
};

export default Page;

簡単な説明

このコンポーネントは、「タイマーの状態(動作中 or 停止中)」と「経過秒数」という2つの状態を管理しています。ユーザー操作(スタート/ストップ/リセット)に応じて、タイマーの実行・停止・リセットを切り替える処理をuseEffect内で制御しています。


ロジック詳細

  • isActive の状態が変化するたびに useEffect が実行されます。
  • setInterval は毎秒1回、カウントアップ処理を行います。
  • useEffectのクリーンアップ関数(return () => clearInterval(...))を活用し、常に古いタイマーを解除してから新しいタイマーを開始することで、重複して複数のタイマーが走ることを防いでいます
  • seconds が変わることで useEffect は再実行されますが、そのたびに前回のタイマーが正しく停止されているため、ロジックは安全に保たれます。

細かい部分の解説

タイマーIDの型について

let interval: NodeJS.Timeout | undefined = undefined;
  • setInterval の戻り値の型は NodeJS.Timeout
  • 初期はタイマーが存在しないので、型として undefined を含める
  • = undefined と明示しているのは、初期状態を明確にするためです

条件分岐:ストップ処理

else if (!isActive && seconds !== 0) {
  clearInterval(interval);
}
  • 停止状態(!isActive)かつ、秒数が0でない場合だけ clearInterval を呼びます
  • これは「すでにタイマーが動いていた場合のみ、止める必要がある」からです

クリーンアップ関数のタイミング

return () => clearInterval(interval);
  • useEffectが再実行される直前に必ず呼ばれます
  • 前の setInterval を明示的に解除することで、タイマーが多重に動作するのを防ぎます
  • ポイント!!このコードだと毎秒secondsが変わるため、毎秒useeffectが走り新しいintervalが作成されちゃうからintervalが重なってバグるのでは?と思う方もいるでしょう。・
    • その対策で再度useeffectが走る前にクリーンアップ関数で前のintervalを削除しています。少し凍のロジックがややこしいですね。

コメント・フィードバック歓迎

補足や疑問点、もっと良い書き方があればぜひコメントお願いします!

2
2
0

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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?