🚀

【React】reCAPTCHAからCloudflare Turnstileへ移行する際にハマったこと

に公開

Cloudflare Turnstileとは

ボット対策といえば画像を選択させるreCAPTCHAが有名かもしれません。
ですが、手動でのクリック操作によるUXの低下や料金等の問題が存在しており、その点Cloudflare Turnstileは自動で認証でき、無料で利用できる非常に素晴らしい手法です。

※ reCAPTCHAもv3は自動で認証が可能です。料金面の問題は以前として存在していますが。

詳しい説明や導入方法はここでは割愛します。AIに聞いたらパパッとやってくれます。
記事も無限に存在するので、いい感じのを見つけてください。
https://www.value-domain.com/media/cloudflare-turnstile-about/

問題: 認証の無限ループ

サーバーから認証結果を取得して状態を更新するという点で、reCAPTCHAもTurnstileも認証の流れはほとんど変わりません。

そのため、適当に差し替えればいいと思っていたところ、Turnstileが「認証→グルグル→認証→グルグル→...」という恐ろしい事態になってしまいました。

原因

認証成功時の変数の更新に伴って画面が再描画されているのが問題でした。

認証に成功した直後、Turnstileの状態が保持されないまま画面が再描画されてしまい、自動で再度認証しようとするループにハマっていたようです。

では、どうしてreCAPTCHAだとこの問題が発生しなかったのでしょうか。

Cloudflare Turnstile と reCAPTCHAの挙動の違い

両者の挙動の違いは、Reactの再描画に対するライブラリの設計思想の違いにあります。

reCAPTCHAのラッパーライブラリ(react-google-recaptchaなど)は、propsの変更に反応しない(Non-reactive) 設計になっています。propsとして渡す関数が再描画で再生成されたとしても、ライブラリ側がそれを検知してウィジェットを更新する機能を持たないため、結果として状態がリセットされません。良くも悪くも「一度描画したらそのまま」という挙動のため、意図せず再認証が走る問題が起きなかったのです。
https://www.npmjs.com/package/react-google-recaptcha

一方でTurnstileのReactラッパーは、よりシンプルでCloudflareのコアAPIに忠実な作りになっています。公式ドキュメントを見ると、Turnstileはturnstile.render()という関数にコールバック関数などを含む設定オブジェクトを渡して描画します。そもそもTurnstileのネイティブAPI自体が、ウィジェットを動的に削除(turnstile.remove())したり再描画したりする機能を提供しており、非常に柔軟です。そのため、ラッパーライブラリもその機能を活かし、propsの変更に機敏に反応するモダンな設計 になっているのです。propsとして渡されたonSuccessなどのコールバック関数が再描画で新しいインスタンスに変わると、ライブラリがこれを検知してウィジェットを再初期化してしまうため、状態がリセットされるのです。
https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicitly-render-the-turnstile-widget

これはどちらが優れているという話ではなく、TurnstileをReactで利用する際は、開発者がReactの作法に則って、より明示的に再描画を制御する必要がある、ということです。

解決策

解決策は、ReactのuseCallbackフックを使い、Turnstileコンポーネントのpropsとして渡すコールバック関数をメモ化することです。これにより、親コンポーネントが再描画されても関数は再生成されず、Turnstileの状態がリセットされるのを防ぎます。

import { useState, useCallback } from 'react';
import { Turnstile } from '@marsidev/react-turnstile';

export default function ContactForm() {
  const [name, setName] = useState('');
  const [turnstileToken, setTurnstileToken] = useState('');

  // 💡 useCallbackで関数をメモ化(インスタンスを固定)する
  const handleTurnstileSuccess = useCallback((token: string) => {
    console.log("認証成功:", token);
    setTurnstileToken(token);
  }, []); // 依存配列が空なので、この関数はコンポーネントの初回レンダリング時にのみ生成される

  // ...フォームの送信処理などは省略...

  return (
    <form>
      <label>
        お名前:
        <input type="text" value={name} onChange={(e) => setName(e.target.value)} />
      </label>

      {/* 認証ウィジェット */}
      <div style={{ margin: '1rem 0' }}>
        <Turnstile
          siteKey="YOUR_SITE_KEY"
          onSuccess={handleTurnstileSuccess}
        />
      </div>

      <button type="submit" disabled={!turnstileToken}>
        送信
      </button>
    </form>
  );
}

最後まで読んでいただきありがとうございました!

Discussion