【react+typescript】信号機アプリを作っていろんな型と状態管理に慣れよう

# 作るもの

信号機のように3つのライト（赤・黄・緑）が1秒ごとに切り替わるUIをReact + TypeScriptで実装します。

### こんな感じ

![画面録画 2025-03-26 234710.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909033/0c1a1453-9148-4c64-b497-1c91598b0124.gif)

---

# 概要

- Reactの`useEffect`と`useState`を使って1秒ごとに状態を更新
- TypeScriptの型定義で安全に状態を扱う
- `setTimeout`で1秒後に状態を切り替え、再レンダリングを誘発
- `clearTimeout`で不要なタイマーをクリーンアップ

---

# コードの全体像

```tsx
import { NextPage } from 'next';
import { useEffect, useState } from 'react';

const Page: NextPage = () => {
  const CYCLE = { red: 'green', green: 'yellow', yellow: 'red' } as const;
  const [light, setlight] = useState<keyof typeof CYCLE>('red');

  useEffect(() => {
    const timeID = setTimeout(() => {
      const nextLight = CYCLE[light];
      setlight(nextLight);
      console.log('再レンダリング');
    }, 1000);

    return () => clearTimeout(timeID);
  }, [light]);

  return (
    <div className="mx-auto max-w-4xl">
      <div className="mt-10 flex justify-center gap-x-8">
        <div className={`size-12 rounded-full ${light === 'red' ? 'bg-red-700' : 'bg-gray-700'}`} />
        <div className={`size-12 rounded-full ${light === 'green' ? 'bg-green-700' : 'bg-gray-700'}`} />
        <div className={`size-12 rounded-full ${light === 'yellow' ? 'bg-yellow-500' : 'bg-gray-700'}`} />
      </div>
    </div>
  );
};

export default Page;
```

---

# コードを日本語化して見てみる

- `CYCLE`：信号の色の遷移を定義した定数オブジェクト
- `useState`：現在の信号の色（light）を保持。初期値は'red'
- `useEffect`：`light`が変わるたびに1秒後の色に更新するタイマーをセット
- `setTimeout`：1秒後に`light`を次の色に切り替え
- `clearTimeout`：次のレンダリング前に前のタイマーを解除し、バグやリークを防ぐ
- `return`：JSXで3つのライトを表現。`light`の状態に応じて色を変える

---

# 各コードの詳細

## 型定義の2つについて

```ts
const CYCLE = { red: 'green', green: 'yellow', yellow: 'red' } as const;
```
- `as const`：オブジェクトをリテラル型として扱い、値が固定されるようにする。
- 結果的に`CYCLE`の値が"green"などの文字列リテラルとして認識される。

```ts
const [light, setlight] = useState<keyof typeof CYCLE>('red');
```
- `typeof CYCLE`：オブジェクト`CYCLE`の型を取得
- `keyof`：その型のキー（'red' | 'green' | 'yellow'）を取り出す
- `light`はこの3つのどれかしか入れられなくなり、型安全になる

## useEffect内のロジック

- `setTimeout`：1秒後に次の色へ切り替える関数を実行
- `CYCLE[light]`：現在の色に応じて次の色を取得
- `setlight(nextLight)`：状態を更新して再レンダリングを誘発
- `clearTimeout(timeID)`：レンダリング前に前のタイマーをキャンセル

---

# おまけ：カスタムフック化

自分は以下のような Next.js + TypeScript 環境を使っています。
`hooks` ディレクトリに切り出すことで再利用性が上がります。

```tsx
// src/hooks/useTrafficLight.ts
export const useTrafficLight = () => {
  const CYCLE = { red: 'green', green: 'yellow', yellow: 'red' } as const;
  const [light, setlight] = useState<keyof typeof CYCLE>('red');

  useEffect(() => {
    const timer = setTimeout(() => {
      setlight(CYCLE[light]);
    }, 1000);
    return () => clearTimeout(timer);
  }, [light]);

  return { light };
};
```

```tsx
// src/pages/index.tsx
import { useTrafficLight } from '@/hooks/useTrafficLight';

const Page = () => {
  const { light } = useTrafficLight();

  return (
    <div className="mx-auto max-w-4xl">
      <div className="mt-10 flex justify-center gap-x-8">
        <div className={`size-12 rounded-full ${light === 'red' ? 'bg-red-700' : 'bg-gray-700'}`} />
        <div className={`size-12 rounded-full ${light === 'green' ? 'bg-green-700' : 'bg-gray-700'}`} />
        <div className={`size-12 rounded-full ${light === 'yellow' ? 'bg-yellow-500' : 'bg-gray-700'}`} />
      </div>
    </div>
  );
};

export default Page;
```

疑問点などありましたらコメントいただけると幸いです🙇