🍣

React で Counter を作成

2024/05/03に公開

概要

React / Next.js で Counter を作成する方法を紹介します。

結論

Counter アプリで利用するカスタムフックスの useCounter の正しい実装は以下のようになります。

import { useState } from "react";

type Props = {
  initialCount?: number;
};

// Redefine ReturnType to match the tuple return style
type ReturnType = [number, {
  increment: () => void;
  decrement: () => void;
}];

export const useCounter = ({ initialCount = 0 }: Props): ReturnType => {
  const [count, setCount] = useState(initialCount);

  // ここの実装が修正されました
  const increment = () => setCount((prevCount) => prevCount + 1);
  const decrement = () => setCount((prevCount) => prevCount - 1);

  return [ count, {increment, decrement} ];
};

以下のような実装は誤っています。

import { useState } from "react";

type Props = {
  initialCount?: number;
};

// Redefine ReturnType to match the tuple return style
type ReturnType = [number, {
  increment: () => void;
  decrement: () => void;
}];

export const useCounter = ({ initialCount = 0 }: Props): ReturnType => {
  const [count, setCount] = useState(initialCount);

  // ここの実装が誤っています
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);

  return [ count, {increment, decrement} ];
};

Next.jsプロジェクトの作成

動作確認するための Next.js プロジェクトを作成します。長いので、折り畳んでおきます。

新規プロジェクト作成と初期環境構築の手順詳細

プロジェクトの作成

create next-app@latestでプロジェクトを作成します。

$ pnpm create next-app@latest next-counter --typescript --eslint --import-alias "@/*" --src-dir --use-pnpm --tailwind --app
$ cd next-counter

Peer Dependenciesの警告を解消

Peer dependenciesの警告が出ている場合は、pnpm installを実行し、警告を解消します。

 WARN  Issues with peer dependencies found
.
├─┬ eslint-config-next 14.2.1
│ └─┬ @typescript-eslint/parser 7.2.0
│   └── ✕ unmet peer eslint@^8.56.0: found 8.0.0
└─┬ next 14.2.1
  ├── ✕ unmet peer react@^18.2.0: found 18.0.0
  └── ✕ unmet peer react-dom@^18.2.0: found 18.0.0

以下を実行することで警告が解消されます。

$ pnpm i -D eslint@^8.56.0
$ pnpm i react@^18.2.0 react-dom@^18.2.0

不要な設定を削除し、プロジェクトを初期化します。

styles

CSSなどを管理するstylesディレクトリを作成します。globals.cssを移動します。

$ mkdir -p src/styles
$ mv src/app/globals.css src/styles/globals.css

globals.cssの内容を以下のように上書きします。

src/styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

初期ページ

app/page.tsxを上書きします。

src/app/page.tsx
import { type FC } from "react";

const Home: FC = () => {
  return (
    <div className="">
      <div className="text-lg font-bold">Home</div>
      <div>
        <span className="text-blue-500">Hello</span>
        <span className="text-red-500">World</span>
      </div>
    </div>
  );
};

export default Home;

レイアウト

app/layout.tsxを上書きします。

src/app/layout.tsx
import "@/styles/globals.css";
import { type FC } from "react";
type RootLayoutProps = {
  children: React.ReactNode;
};

export const metadata = {
  title: "Sample",
  description: "Generated by create next app",
};

const RootLayout: FC<RootLayoutProps> = (props) => {
  return (
    <html lang="ja">
      <body className="">{props.children}</body>
    </html>
  );
};

export default RootLayout;

TailwindCSSの設定

TailwindCSSの設定を上書きします。

tailwind.config.ts
import type { Config } from 'tailwindcss'

const config: Config = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  plugins: [],
}
export default config

TypeScriptの設定

TypeScriptの設定を上書きします。

tsconfig.json
{
  "compilerOptions": {
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "baseUrl": ".",
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

スクリプトを追加

型チェックのスクリプトを追加します。

package.json
{
  "name": "next-tsconfig-resolvejsonmodule",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
+   "typecheck": "tsc"
  },
  "dependencies": {
    "next": "14.1.4"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10.0.1",
    "eslint": "^8",
    "eslint-config-next": "14.1.4",
    "postcss": "^8.4.38",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "tailwindcss": "^3.3.0",
    "typescript": "^5"
  }
}

動作確認

ローカルで動作確認します。

$ pnpm run dev

コミットして作業結果を保存しておきます。

$ git add .
$ git commit -m "feat:新規にプロジェクトを作成し, 作業環境を構築"

誤った Counter の実装

よくある誤った Counter の実装します。

hooks を作成

useCounter というカスタムフックスを作成します。(カスタムフックスについては公式ドキュメントを参照してください)

$ mkdir -p src/hooks
$ touch src/hooks/use-counter-1.ts
src/hooks/use-counter-1.ts
import { useState } from "react";

type Props = {
  initialCount?: number;
};

// Redefine ReturnType to match the tuple return style
type ReturnType = [number, {
  increment: () => void;
  decrement: () => void;
}];

export const useCounter = ({ initialCount = 0 }: Props): ReturnType => {
  const [count, setCount] = useState(initialCount);

  // ここの実装が誤っています
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);

  return [ count, {increment, decrement} ];
};

コンポーネントを作成

Counter コンポーネントを使用します。

$ mkdir -p src/components
$ touch src/components/counter-1.tsx
src/components/counter-1.tsx
"use client";

import { useCounter } from "@/hooks/use-counter-1";
import { type FC } from "react";

type Props = {
  initialCount?: number;
};

const Counter1: FC<Props> = ({}) => {
  const [count, { increment, decrement }] = useCounter({ initialCount: 0 });

  const incrementByTwo = () => {
    increment();
    increment();
  };

  const decrementByTwo = () => {
    decrement();
    decrement();
  };

  return (
    <div className="px-4 py-3 border-2 flex flex-col space-y-4 mx-2 mt-2">
      <h1 className="text-lg font-bold text-slate-800">
        ❌ 誤った useCounter の実装
      </h1>
      <div className="bg-gray-100 py-2 px-3 text-slate-800 flex flex-col space-y-2">
        <span>
          この例では、「2
          増やす」ボタンをクリックしても1つしか増えません。また、「2
          減らす」ボタンをクリックしても1つか減りません。
        </span>
        <div className="flex flex-row space-x-2">
          <span className="px-2 py-1 bg-blue-100 text-sm rounded-md text-slate-700">
            counter-1.tsx
          </span>
          <span className="px-2 py-1 bg-blue-100 text-sm rounded-md text-slate-700">
            use-counter-1.ts
          </span>
        </div>
      </div>
      <div>
        <span className="px-2 py-1 bg-blue-100 text-sm rounded-md text-slate-700">
          count = {count}
        </span>
      </div>
      <div className="flex flex-row space-x-2">
        <button
          onClick={decrementByTwo}
          className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
        >
          2 減らす
        </button>
        <button
          onClick={decrement}
          className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
        >
          1 減らす
        </button>
        <button
          onClick={increment}
          className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
        >
          1 増やす
        </button>
        <button
          onClick={incrementByTwo}
          className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
        >
          2 増やす
        </button>
      </div>
    </div>
  );
};

export default Counter1;

コンポーネントを使用

src/pages/index.tsx を上書きします。

src/pages/index.tsx
import Counter1 from "@/components/counter-1";
import { type FC } from "react";

const Home: FC = () => {
  return (
    <div className="grid grid-row-2">
      <Counter1 />
    </div>
  );
};

export default Home;

コミットします。

$ git add .
$ git commit -m "feat: add counter component"

動作確認

ローカルで動作確認します。

$ pnpm run dev

「2 増やす」ボタンをクリックしても 1 つしか増えません。同様に、「2 減らす」ボタンをクリックしても 1 つか減りません。

https://twitter.com/hayato94087/status/1786242547054231909

「2 増やす」ボタンをクリックした場合の挙動について解説します。「2 増やす」ボタンをすると以下のコードが実行されます。

const incrementByTwo = () => {
  increment();
  increment();
};

increment 関数は以下のように実装されています。

  const increment = () => setCount(count + 1);

increment 関数では count の値に対して 1 を加算しています。しかし、setCount は非同期関数であるため、count の値が更新される前に setCount が 2 度呼び出されるため、count の値が更新されないまま、1 を追加しています。

分かりやすく説明すると、現在の count の値が 2 の場合、「2 増やす」ボタンをクリックすると以下のような挙動になります。

2 + 1 = 3
2 + 1 = 3

期待する挙動はこちらです。

2 + 1 = 3
3 + 1 = 4

以下でこの実装を修正します。

正しい Counter の実装

ここでは正しい Counter の実装します。

hooks を作成

useCounter というカスタムフックスを作成します。(カスタムフックスについては公式ドキュメントを参照してください)

$ touch src/hooks/use-counter-2.ts
src/hooks/use-counter-2.ts
import { useState } from "react";

type Props = {
  initialCount?: number;
};

// Redefine ReturnType to match the tuple return style
type ReturnType = [number, {
  increment: () => void;
  decrement: () => void;
}];

export const useCounter = ({ initialCount = 0 }: Props): ReturnType => {
  const [count, setCount] = useState(initialCount);

  // ここの実装が修正されました
  const increment = () => setCount((prevCount) => prevCount + 1);
  const decrement = () => setCount((prevCount) => prevCount - 1);

  return [ count, {increment, decrement} ];
};

setCount を以下のように修正しました。useState で定義する setXXX は引数として変更前の値を参照できます。この値を用いて、新しい値を計算することで、非同期処理による値の競合を回避できます。

  const increment = () => setCount((prevCount) => prevCount + 1);
  const decrement = () => setCount((prevCount) => prevCount - 1);

コンポーネントを作成

Counter コンポーネントを使用します。

$ touch src/components/counter-2.tsx
src/components/counter-2.tsx
"use client";

import { useCounter } from "@/hooks/use-counter-2";
import { type FC } from "react";

type Props = {
  initialCount?: number;
};

const Counter2: FC<Props> = ({}) => {
  const [count, { increment, decrement }] = useCounter({ initialCount: 0 });

  const incrementByTwo = () => {
    increment();
    increment();
  };

  const decrementByTwo = () => {
    decrement();
    decrement();
  };

  return (
    <div className="px-4 py-3 border-2 flex flex-col space-y-4 mx-2 mt-2">
      <h1 className="text-lg font-bold text-slate-800">
        ⭕ 正しい useCounter の実装
      </h1>
      <div className="bg-gray-100 py-2 px-3 text-slate-800 flex flex-col space-y-2">
        <span>
          この例では、「2
          増やす」ボタンをクリックするとしっかり2つ増えます。また、「2
          減らす」ボタンをクリックするとしっかり2つか減ります。
        </span>
        <div className="flex flex-row space-x-2">
          <span className="px-2 py-1 bg-blue-100 text-sm rounded-md text-slate-700">
            counter-2.tsx
          </span>
          <span className="px-2 py-1 bg-blue-100 text-sm rounded-md text-slate-700">
            use-counter-2.ts
          </span>
        </div>
      </div>
      <div>
        <span className="px-2 py-1 bg-blue-100 text-sm rounded-md text-slate-700">
          count = {count}
        </span>
      </div>
      <div className="flex flex-row space-x-2">
        <button
          onClick={decrementByTwo}
          className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
        >
          2 減らす
        </button>
        <button
          onClick={decrement}
          className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
        >
          1 減らす
        </button>
        <button
          onClick={increment}
          className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
        >
          1 増やす
        </button>
        <button
          onClick={incrementByTwo}
          className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
        >
          2 増やす
        </button>
      </div>
    </div>
  );
};

export default Counter2;

コンポーネントを使用

src/pages/index.tsx を上書きします。

src/pages/index.tsx
import Counter1 from "@/components/counter-1";
import Counter2 from "@/components/counter-2";
import { type FC } from "react";

const Home: FC = () => {
  return (
    <div className="grid grid-row-2">
      <Counter1 />
      <Counter2 />
    </div>
  );
};

export default Home;

コミットします。

$ git add .
$ git commit -m "feat: add counter component"

動作確認

ローカルで動作確認します。

$ pnpm run dev

https://twitter.com/hayato94087/status/1786242561193189491

まとめ

useState を利用しカウンターを作成しました。useState を利用する際には、必要に応じて前回の値を参し値を正しく更新しましょう。

作業リポジトリとはこちらになります。

https://github.com/hayato94087/next-counter

参考

https://zenn.dev/stin/articles/use-appropriate-api

Discussion