【Next.js】チケット販売システムを作る① : setInterval()を活用し、カウントダウンを実装

Programming

K-POPの人気が続く中、韓国のコンサートチケットの取得方法は日本とは少し異なります。
日本では抽選制が主流ですが、韓国ではチケット販売サイトで直接座席を選んで購入する方式が一般的です。その中でも特に利用者が多いのが NOL(Interpark) です。

実際の動作を参考にしながら、Next.jsで簡易的な座席予約システムを再現してみました。

この記事でわかること
  • setInterval()の使い方(例:カウントダウン実装)
  • useStateによる状態管理の考え方(例:時間管理)
  • useEffectとタイマー処理の注意点
  • クリーンアップ処理の重要性
  • コンポーネント設計(ロジックとUIの分離)

今回の目標

人気のコンサートは、販売開始から1時間も経たずに完売してしまうことも少なくありません。そのため、販売時刻になった瞬間にチケット購入ボタンを押し、できるだけ早く待機列に入ることが成功率を高めるポイントになります。

hyoni
hyoni

残り時間が0秒になった瞬間に、いかに素早く「チケット販売開始」ボタンを押せるかが一番のポイントです!ちなみに、これを作りながら練習してBTSコンサートチケットゲットしました!もちろん、これだけじゃなく、インターネットの速度や運も必要です😭

さらに、クリック速度を測定し、実際のチケット販売日に備えられるような練習サイトを作ることを目的としました。

開発環境

  • Next.js (App Router)
  • React
  • TypeScript
  • Tailwind CSS

アーキテクチャ(簡易)

今回のカウントダウン機能は、ロジックとUIを分離して実装しました。UIとロジックを分けることで、再利用性と可読性を高めることができます。

  • useCountdown(カスタムフック)
    カウントダウンのロジックを管理する部分。
    残り時間の計算や、0秒になった瞬間の状態変更を担当します。
  • BookingWait(待機画面コンポーネント)
    カウントダウンの表示やボタンのUIを担当します。
    isOpenの状態に応じてボタンの有効・無効を切り替えます。
  • ページコンポーネント(状態管理)
    全体の画面遷移やステップ管理を担当します。

useCountdown(カスタムフック)

useCountdownを使って以下の機能を実装します。

  1. useEffect内でカウントダウン開始(コンポーネントマウント時にカウントダウンスタート)
  2. 残り時間が0msになった瞬間にisOpenをtrueに変更してチケット販売ボタンを活性化
  3. performance.now()でオープン時刻を記録

コード説明

目標時刻までのカウントダウンを実装する。

・残り時間(ミリ秒)の管理
・オープン瞬間の検知
・ボタン活性化の制御
・オープン時刻の記録

State変数宣言

・remainingMs → 残り時間(ms)
・isOpen → ボタン活性状態

コードを見る
const [remainingMs, setRemainingMs] = useState<number>(0); 
const [isOpen, setIsOpen] = useState<boolean>(false); 
useRef の役割

・openedRef → 多重実行防止
・openAtRef → オープン瞬間の高精度記録

コードを見る
const openedRef = useRef(false);
const openAtRef = useRef<number | null>(null);
useEffect ロジック部分

・update() で残り時間計算
・16ms更新(約1フレーム)
・targetTime が変わった時のみ再実行

コードを見る
useEffect(() => {
    const update = () => {
        const now = new Date().getTime();

        // 残り時間を計算
        const diff = targetTime.getTime() - now;

        if (diff <= 0) {
            setRemainingMs(0);

            // オープン処理の多重実行を防ぐためのフラグ
            if (!openedRef.current) {
                openedRef.current = true;
                openAtRef.current = performance.now();
                setIsOpen(true);
            }

            // タイマーを停止(これ以上更新しない)
            clearInterval(timer);

        } else {
            // 残り時間を更新
            setRemainingMs(diff);
        }
    };

    // 初期表示時のズレを防ぐため
    update();

    // 約16ms間隔(=約1フレーム)で更新
    const timer = setInterval(update, 16);

    // クリーンアップ処理
    return () => clearInterval(timer);
    
}, [targetTime]);// targetTime が変更された場合のみ再実行
formatTime 関数

ミリ秒 → HH:MM:SS 変換

コードを見る
// ミリ秒を「HH:MM:SS」形式に変換
const formatTime = (ms: number) => {

    // ms → 秒へ変換
    const totalSec = Math.floor(ms / 1000);

    // 時・分・秒をそれぞれ計算
    const hours = Math.floor(totalSec / 3600); 
    const minutes = Math.floor((totalSec % 3600) / 60);
    const seconds = totalSec % 60;

    // 2桁表示に整形(例:5 → 05)
    return `${hours.toString().padStart(2, '0')}:` +
           `${minutes.toString().padStart(2, '0')}:` +
           `${seconds.toString().padStart(2, '0')}`;
};
全体コード

useCountdownのhookの全体コードはこちらです。

コードを見る
"use client";

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


export function useCountdown(targetTime: Date) {

    const [remainingMs, setRemainingMs] = useState<number>(0); 
    const [isOpen, setIsOpen] = useState<boolean>(false);

    const openedRef = useRef(false); 
    const openAtRef = useRef<number | null>(null); 

    useEffect(()=>{
        const update = ()=>{
            const now = new Date().getTime();
            const diff = targetTime.getTime() - now;
            if(diff <= 0){
                setRemainingMs(0);
                if(!openedRef.current){ 
                    openedRef.current = true; 
                    openAtRef.current = performance.now(); 
                    setIsOpen(true); 
                }
                clearInterval(timer); 
            }else{
                setRemainingMs(diff);
            }
        }
        update(); 

        const timer = setInterval(update, 16);
        return () => clearInterval(timer);
    },[targetTime])

    
    const formatTime = (ms: number) => {
        const totalSec = Math.floor(ms / 1000); 
        const hours = Math.floor(totalSec / 3600); 
        const minutes = Math.floor((totalSec % 3600) / 60); 
        const seconds = totalSec % 60; 
        return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
    }

    return {
    remainingText: formatTime(remainingMs),
    isOpen,
    openAtRef, 
  };
}

setInterval()注意点

① Cleanup必須(超重要)

setIntervalはコンポーネントが再レンダリングされても動き続けます。そのため、useEffectのreturn内で
clearIntervalを必ず記述する必要があります。

例を見る
useEffect(() => {
  const timer = setInterval(() => {
    // 処理内容
  }, 1000);

  return () => clearInterval(timer); // 必ずCleanup
}, []);
② Strict Mode問題(Next.jsなら必須)

開発環境ではuseEffectが2回実行されることがあります。そのため、setIntervalも2回動いてしまい、想定より早く時間が減ることがありました。

③ prevを使う理由

setIntervalの中で直接stateを使うと、最新の値が取得できないことがあります。そのため、setTime((prev) => prev – 1)のようにprevを使う必要があります。

NG例
const [count, setCount] = useState(5);

useEffect(() => {
const timer = setInterval(() => {
setCount(count - 1); // ❌最新の値ではないかも
}, 1000);

return () => clearInterval(timer);
}, []);

OK例
const [count, setCount] = useState(5);

useEffect(() => {
 const timer = setInterval(() => {
 setCount((prev) => prev - 1); // ⭕️常に最新の値を取得
 }, 1000);

 return () => clearInterval(timer);
}, []);
④ 依存配列

useEffectの依存配列を正しく設定しないと、intervalが再生成される可能性があります。

NG例
const [count, setCount] = useState(5);

useEffect(() => {
const timer = setInterval(() => {
setCount((prev) => prev - 1);
}, 1000);

return () => clearInterval(timer);
}, [count]); // ❌ ここが問題
OK例
const [count, setCount] = useState(5);

useEffect(() => {
 const timer = setInterval(() => {
 setCount((prev) => prev - 1); // 常に最新の値を取得
 }, 1000);

 return () => clearInterval(timer);
}, []); //⭕️初回のみ実行

BookingWait(待機画面コンポーネント)

ユーザが見るチケット待機画面です。

残り時間、販売開始ボタン、ボタンクリック処理を書いています。

コードを見る
"use client";

type Props = {
  remainingText: string; // ex."00:15:30"
  isOpen: boolean; 
  onClickStart: () => void;
};

export default function BookingWait({
  remainingText,
  isOpen,
  onClickStart,
}: Props) {

  return (
    <div className="flex flex-col items-center justify-center gap-6">
      <h1 className="text-2xl font-bold">Pre-sale</h1>

      <p className="text-lg text-gray-400">
        Booking Opens In
      </p>

      <div className="text-4xl font-mono">
        {remainingText}
      </div>

      <button
        disabled={!isOpen}
        onClick={()=>{console.log("onClickStart"); onClickStart()}}
        className={`px-8 py-4 rounded text-lg
          ${
            isOpen
              ? "bg-blue-500 text-white"
              : "bg-gray-600 text-gray-400 cursor-not-allowed"
          }`}
      >
        On Sale Now
      </button>
    </div>
  );
}

ページコンポーネント(状態管理)

Pageコンポーネントでは、BookingWait(待機画面コンポーネント) を呼び出し、アプリ全体の状態管理を担当します。

コード説明

  • カウントダウンの開始
  • 販売開始判定
  • クリック反応速度の測定
  • 座席選択画面への遷移
COUNTDOWN_SECONDS

カウントダウンの秒数を定義します。今回は10秒後に販売開始する想定です。

コードを見る
  const COUNTDOWN_SECONDS = 10;
openTime

販売開始時刻を設定します。「現在時刻 + 10秒後」を販売開始時刻として設定し、この時間を基準にカウントダウンを行います。

コードを見る
  const [openTime, setOpenTime] = useState(
    () => new Date(Date.now() + COUNTDOWN_SECONDS * 1000)
  );
useCountdownの呼び出し

カスタムフック useCountdown を呼び出し、以下の販売開始状態を取得します。

・remainingMs → 残り時間

・isOpen → 販売開始状態

ロジックはフック側に分離しているため、Pageコンポーネントは「状態を受け取って制御する役割」に集中できます。

コードを見る
  const { remainingText, isOpen, openAtRef } = useCountdown(openTime);
step(画面の進行管理)

現在どの画面を表示するかを管理します。

・”WAIT” → 待機画面
・”RESULT” → クリック反応速度の結果画面

販売開始ボタンが押されたら、stepを変更して画面を切り替えます。

コードを見る
  const [step, setStep] = useState<Step>("WAIT"); 
types/step.ts
export type Step ="WAIT" | "RESULT"
reactionMs(クリック反応速度)

0秒になった瞬間から、ユーザーがボタンを押すまでの時間を計測します。

コードを見る
  const [reactionMs, setReactionMs] = useState<number | null>(null);
handleStartの役割

販売開始ボタンがクリックされた瞬間の反応速度(reaction time)を計測する処理です。

・if(!openAtRef.current) → 販売開始時刻が記録されていない場合は処理を中断
・performance.now() → クリック時刻を取得
・clickedAt – openAtRef.current → 反応速度を計算
・setReactionMs(Math.floor(reaction)) → ミリ秒単位で反応速度を保存
・setStep(“RESULT”) → 結果表示画面へ切り替え

コードを見る
const handleStart = () => {
  if (!openAtRef.current) return;

  const clickedAt = performance.now();
  const reaction = clickedAt - openAtRef.current;

  setReactionMs(Math.floor(reaction));
  setStep("RESULT");
};
Routerで画面遷移

クリック反応速度の結果のあと、座席販売開始ボタンをクリックしたら座席選択画面へ遷移します。これにより、実際のチケットサイトに近い動作を再現しています。

コードを見る
const router = useRouter(); //Routerの宣言 

onClick={()=>router.push("/seat")}>
ユーザーが見る画面の切り替え

カウントダウン画面からクリック反応速度の結果画面へ切り替えるため、step の状態を使って 条件付きレンダリング を行っています。

・"WAIT" → カウントダウン画面
・"RESULT" → 反応速度の結果画面

コードを見る
{step === "WAIT" && (
        <BookingWait
          remainingText={remainingText}
          isOpen={isOpen}
          onClickStart={handleStart}
        />
 )}

{step === "RESULT" && (
        <div className="text-center">
          <h2 className="text-2xl mb-4">Your Speed</h2>
          <p className="text-4xl font-mono">
            {reactionMs} ms
          </p>
          <div>
            <button 
              type="button"
              className="mt-60 px-8 py-4 rounded text-lg border-2 cursor-pointer hover:bg-blue-500 hover:text-white transition"
              onClick={()=>router.push("/seat")}>
              Select Seats
            </button>
          </div>
        </div>
      )}
全体コード

全体コードはこちらになります。

コードを見る
"use client";


import BookingWait from "@/components/booking/BookingWait";
import { useCountdown } from "@/hooks/useCountdown";
import { Step } from "@/types/step";
import { useRouter } from "next/navigation"; 
import { useState } from "react";

export default function Home() {
  const router = useRouter();

  const COUNTDOWN_SECONDS = 10;

  const [openTime, setOpenTime] = useState(
    () => new Date(Date.now() + COUNTDOWN_SECONDS * 1000)
  );

  const { remainingText, isOpen, openAtRef } = useCountdown(openTime);

  const [reactionMs, setReactionMs] = useState<number | null>(null);

  const [step, setStep] = useState<Step>("WAIT"); 

  const handleStart = () => {
    if(!openAtRef.current) return;

    const clickedAt = performance.now();
    const reaction = clickedAt - openAtRef.current;

    setReactionMs(Math.floor(reaction));
    setStep("RESULT");

    }

  return (
    <div className="flex flex-col md:flex-row min-h-screen items-center justify-between p-20 bg-zinc-50 font-sans dark:bg-black">
     <div>
      <h1 className="text-4xl font-bold mt-20">NOL Ticket Practice</h1>
     </div>
     {step === "WAIT" && (
        <BookingWait
          remainingText={remainingText}
          isOpen={isOpen}
          onClickStart={handleStart}
        />
      )}

      {step === "RESULT" && (
        <div className="text-center">
          <h2 className="text-2xl mb-4">Your Speed</h2>
          <p className="text-4xl font-mono">
            {reactionMs} ms
          </p>
          <div>
            <button 
              type="button"
              className="mt-60 px-8 py-4 rounded text-lg border-2 cursor-pointer hover:bg-blue-500 hover:text-white transition"
              onClick={()=>router.push("/seat")}>
              Select Seats
            </button>
          </div>
        </div>
      )}
    </div>
  );
}






終わりに

実は、BTSのコンサートに行くためのチケッティング対策として、勉強がてら本アプリを作ってみました。

タイマーの精度やクリック反応速度の測定を通して、setIntervalの特性やuseEffectのクリーンアップの重要性を改めて理解する良い機会になりました。

現在は、座席選択画面の実装についても整理中です。次回はそのコード構成や状態管理の設計について解説する予定です。

次回の記事もぜひ楽しみにしていてください!

タイトルとURLをコピーしました