【React】reCAPTCHAからCloudflare Turnstileへ移行する際にハマったこと
※ 当記事は Zenn に投稿したものと同じ内容です。

Cloudflare Turnstileとは
ボット対策といえば画像を選択させる reCAPTCHA が有名かもしれません。 ですが、手動でのクリック操作による UX の低下や料金等の問題が存在しており、その点 Cloudflare Turnstile は自動で認証でき、無料で利用できる非常に素晴らしい手法です。
※ reCAPTCHA も v3 は自動で認証が可能です。料金面の問題は以前として存在していますが。
詳しい説明や導入方法はここでは割愛します。AI に聞いたらパパッとやってくれます。 記事も無限に存在するので、いい感じのを見つけてください。

問題: 認証の無限ループ
サーバーから認証結果を取得して状態を更新するという点で、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などのコールバック関数が再描画で新しいインスタンスに変わると、ライブラリがこれを検知してウィジェットを再初期化してしまうため、状態がリセットされるのです。

これはどちらが優れているという話ではなく、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>
);
}
最後まで読んでいただきありがとうございました!