はじめに

Hello, ~~Swift~~ Typescript ラバーなみなさん。これからTypeScriptラバーになるみなさん。

なんと(?)今日は Swift 関連ではなく、今日は React + Typescript のお話になります。

アプリケーション開発をしていると一度はボタンの連打問題に悩まされますよね(?)。

今日はそんな問題を解決する方法を考えましたので、記事にしてみました。

とってもライトな記事です。

実装

環境

  • npm: 10.2.2
  • Node.js: 20.11.0
  • Typescript: 4.9.5

準備

環境についてはある程度の理解があるものとします。

…が、create-react-appが素直に動かないじゃないか…

調べてみた結果、React 公式からcreate-react-appは外されているようですね…

ここは追々記事にする(かもしれません)。

一旦の環境構築

一旦の回避策を記載します。(2025/1/22 時点)

任意のディレクトリで、プロジェクトを作成します。

$ npx create-react-app sample_blocking_button template --typescript

そうすると以下のエラーが出ます。

Installing template dependencies using npm...
npm ERR! code ERESOLVE
npm ERR! ERESOLVE unable to resolve dependency tree
npm ERR!
npm ERR! While resolving: sample2@0.1.0
npm ERR! Found: react@19.0.0
npm ERR! node_modules/react
npm ERR!   react@"^19.0.0" from the root project
npm ERR!
npm ERR! Could not resolve dependency:
npm ERR! peer react@"^18.0.0" from @testing-library/react@13.4.0
npm ERR! node_modules/@testing-library/react
npm ERR!   @testing-library/react@"^13.0.0" from the root project
npm ERR!
npm ERR! Fix the upstream dependency conflict, or retry
npm ERR! this command with --force or --legacy-peer-deps
npm ERR! to accept an incorrect (and potentially broken) dependency resolution.
npm ERR!
npm ERR!
npm ERR! For a full report see:
npm ERR! /Users/<User name>/.npm/_logs/2025-01-17T06_35_58_074Z-eresolve-report.txt

npm ERR! A complete log of this run can be found in: /Users/<User name>/.npm/_logs/2025-01-17T06_35_58_074Z-debug-0.log
`npm install --no-audit --save @testing-library/jest-dom@^5.14.1 @testing-library/react@^13.0.0 @testing-library/user-event@^13.2.1 @types/jest@^27.0.1 @types/node@^16.7.13 @types/react@^18.0.0 @types/react-dom@^18.0.0 typescript@^4.4.2 web-vitals@^2.1.0` failed\

私の環境では、まず React 周りのバージョンが新しすぎるとダメだったようで、手動で package.jsonを書き換えました。

{
  "name": "sample",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    + "@types/react": "^18.2.0",
    + "@types/react-dom": "^18.2.0",
    "cra-template-typescript": "1.2.0",
    - "react": "^19.0.0",
    - "react-dom": "^19.0.0",
    + "react": "^18.2.0",
    + "react-dom": "^18.2.0",
    "react-scripts": "5.0.1"
  },
  + "devDependencies": {
  +     "typescript": "^4.9.5"
  + },
...
}

もう一度インストール

$ npm install

ここまで来れば動かすことはできます。

ただ、typescript の拡張子の解決ができていないため、import 部分に.tsx.tsを追加する必要があります。

まぁ、今回はあくまでサンプルですので・・

本題

今回は 2 パターンの実装を行います。

  1. クリック時に、そのボタンのみ連打対策を行うパターン
  2. クリック時に、すべてのボタンで連打対策を行うパターン

1. クリック時に、そのボタンのみ連打対策を行うパターン

1. 最初に一秒間待機のためのカスタムフックを作成

import { useState, useCallback } from "react";

type ClickBlock = {
    isBlocked: boolean; // ブロック中か
    block: () => void; // クリックアクションでブロックする
};

export const useClickBlock = (): ClickBlock => {
    const [isBlocked, setIsBlocked] = useState(false);

    // ボタンクリック時にblock()アクションを実行、1秒後にisBlockedを解除する
    const block = useCallback(() => {
        if (!isBlocked) {
            setIsBlocked(true);
            setTimeout(() => {
                setIsBlocked(false);
            }, 1000);
        }
    }, [isBlocked]);

    return { isBlocked, block };
};

2. 次にカスタムフックを実装したボタンを用意

import React from "react";
import { useClickBlock } from "./useClickBlock.ts";

type ClickBlockButtonProps = {
    children: React.ReactNode; // ボタンの内部に表示するオブジェクト
    onClick: () => void; // クリックアクション
    disabled?: boolean; // ボタンの無効化
    style?: React.CSSProperties; // ボタンのスタイル
    className?: string; // ボタンのクラス名
};

const ClickBlockButton: React.FC<ClickBlockButtonProps> = ({
    children,
    onClick,
    disabled,
    style,
    className,
}) => {
    const { isBlocked, block } = useClickBlock();
    const isDisabled = disabled || isBlocked;

    const handleClick = () => {
        if (!isDisabled) {
            block(); // クリックブロックの開始
            onClick(); // クリックアクション
        }
    };

    // ここは基本的に好きにしてください
    const combinedStyle: React.CSSProperties = {
        padding: "10px 20px",
        fontSize: "16px",
        fontWeight: "bold",
        borderRadius: "5px",
        border: "none",
        cursor: isDisabled ? "not-allowed" : "pointer",
        color: "#fff",
        ...style,
        backgroundColor: isDisabled
            ? "#ccc"
            : style?.backgroundColor ?? "#007bff",
    };

    // 通常の<button>要素とだいたい同じように使える
    return (
        <button
            onClick={handleClick}
            disabled={isDisabled}
            style={combinedStyle}
            className={className}
        >
            {children}
        </button>
    );
};

export default ClickBlockButton;

3. 使用方法

  • ただの緑色のボタン
<ClickBlockButton
    onClick={() => console.log("Primary button clicked")}
    style={{ backgroundColor: "green" }}
>
    Primary Button
</ClickBlockButton>

  • アイコン付きボタン
<ClickBlockButton
    onClick={() => console.log("Button with Icon Clicked")}
    style={{
        backgroundColor: "#28a745",
        color: "white",
        display: "flex",
        alignItems: "center",
        gap: "10px",
    }}
>
    <img
        src="/favicon.ico"
        alt="icon"
        style={{ width: "20px", height: "20px" }}
    />
    <span>Button with Icon</span>
</ClickBlockButton>

  • Disableなボタン
<ClickBlockButton disabled={true} onClick={() => {}}>
    Disabled Button
</ClickBlockButton>

実装後、こんな感じになりました。

基本的にはボタンの使い方は通常の<button />と同様のため、問題ないかと思います。

もちろんすべての属性を実装したわけではないので、用途に合わせて拡張してください。

2. クリック時に、すべてのボタンで連打対策を行うパターン

一度ボタンを押すと、すべてのボタンを無効にしたいパターンってありませんか?

私はありました。

なので、そのボタンも紹介します。

1. 最初にグローバルに状態を管理したいので、カスタムコンテキストを作成

import React, { createContext, useCallback, useContext, useState } from "react";

type ClickBlockContextType = {
    isBlocked: boolean;
    block: () => void;
};

const ClickBlockContext = createContext<ClickBlockContextType | undefined>(
    undefined
);

export const ClickBlockProvider: React.FC<{ children: React.ReactNode }> = ({
    children,
}) => {
    const [isBlocked, setIsBlocked] = useState(false);

    // 基本的には先程のカスタムフックと同じ
    // グローバルで状態管理するため、カスタムコンテキストとする
    const block = useCallback(() => {
        setIsBlocked(true);
        setTimeout(() => {
            setIsBlocked(false);
        }, 1000);
    }, []);

    return (
        <ClickBlockContext.Provider value={{ isBlocked, block }}>
            {children}
        </ClickBlockContext.Provider>
    );
};

export const useGlobalClickBlock = (): ClickBlockContextType => {
    const context = useContext(ClickBlockContext);
    if (!context) {
        throw new Error(
            "useClickBlock must be used within a ClickBlockProvider"
        );
    }
    return context;
};

2. 次に Provider をルート部分に宣言

import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { ButtonBlockProvider } from "./ButtonBlockContext.tsx";

const root = ReactDOM.createRoot(
    document.getElementById("root") as HTMLElement
);
root.render(
    <React.StrictMode>
        // これ
        <ButtonBlockProvider>
            <App />
        </ButtonBlockProvider>
    </React.StrictMode>
);

ボタンを作成します。

import React from "react";
import { useButtonBlock } from "./ButtonBlockContext.tsx";

type AppButtonProps = {
    children: React.ReactNode;
    onClick?: () => void;
    disabled?: boolean;
    style?: React.CSSProperties;
    className?: string;
};

const GlobalBlockButton: React.FC<AppButtonProps> = ({
    children,
    onClick,
    disabled,
    style = {},
    className = "",
}) => {
    const { isBlocked, block } = useGlobalClickBlock();
    const isDisabled = disabled || isBlocked;

    const handleClick = () => {
        if (!isDisabled && onClick) {
            block();
            onClick();
        }
    };

    const combinedStyle: React.CSSProperties = {
        padding: "10px 20px",
        fontSize: "16px",
        fontWeight: "bold",
        borderRadius: "5px",
        border: "none",
        cursor: isDisabled ? "not-allowed" : "pointer",
        color: "#fff",
        ...style,
        backgroundColor: isDisabled
            ? "#ccc"
            : style?.backgroundColor ?? "#007bff",
    };

    return (
        <button
            onClick={handleClick}
            disabled={isDisabled}
            style={combinedStyle}
            className={className}
        >
            {children}
        </button>
    );
};

export default GlobalBlockButton;

基本的には、先ほどのボタンとほぼ一緒ですね。

3. 使用方法

<GlobalBlockButton onClick={() => console.log("Button 1 clicked")}>
    Button 1
</GlobalBlockButton>

そして実装後はこんな感じ。

おまけ

今回はカスタムコンテキストを作成しましたが、Recoilを使用すればもっと簡単に実装できます。

1. インストール

$ npm install recoil

2. Recoil をルートに宣言

import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { RecoilRoot } from "recoil";

const root = ReactDOM.createRoot(
    document.getElementById("root") as HTMLElement
);
root.render(
    <React.StrictMode>
        // これ
        <RecoilRoot>
            <App />
        </RecoilRoot>
    </React.StrictMode>
);

3. atom の作成(先程のカスタムコンテキストと同等)

import { atom } from "recoil";

export const isButtonBlockedState = atom<boolean>({
    key: "isButtonBlockedState",
    default: false,
});

4. ボタンの改造

import React from "react";
+ import { useRecoilState } from "recoil";
+ import { isButtonBlockedState } from "./atom/isButtonBlockedState";
- import { useGlobalClickBlock } from "../custom_context/ClickBlockContext.tsx";

type AppButtonProps = {
    children: React.ReactNode;
    onClick?: () => void;
    disabled?: boolean;
    style?: React.CSSProperties;
    className?: string;
};

const GlobalBlockButton: React.FC<AppButtonProps> = ({
    children,
    onClick,
    disabled,
    style = {},
    className = "",
}) => {
    - const { isBlocked, block } = useGlobalClickBlock();
    + const [isBlocked, setIsBlocked] = useRecoilState(isButtonBlockedState);
    const isDisabled = disabled || isBlocked;

    const handleClick = () => {
        if (!isDisabled && onClick) {
            - block();

            + setIsBlocked(true);
            onClick();
            + setTimeout(() => setIsBlocked(false), 1000);
        }
    };

    const combinedStyle: React.CSSProperties = {
        padding: "10px 20px",
        fontSize: "16px",
        fontWeight: "bold",
        borderRadius: "5px",
        border: "none",
        cursor: isDisabled ? "not-allowed" : "pointer",
        color: "#fff",
        ...style,
        backgroundColor: isDisabled
            ? "#ccc"
            : style?.backgroundColor ?? "#007bff",
    };

    return (
        <button
            onClick={handleClick}
            disabled={isDisabled}
            style={combinedStyle}
            className={className}
        >
            {children}
        </button>
    );
};

export default GlobalBlockButton;

ほぼ変わらないですね!

まとめ

動画の通り、両方ともボタンクリック後に一秒間のボタンクリックブロックを行っています。

用途によって、グローバルに管理するか、個別に管理するか、または特定のボタンだけをブロックするかを選択できますね。

ネットワークリクエストが発生する場合など、余計な不具合につながる事があるので、適切にボタンクリックブロックを実装しましょう!

それでは、よき開発ライフを!!



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる