Understanding the React Turnstile widget lifecycle from mount to cleanup
The React Turnstile widget follows a predictable lifecycle from initialization through rendering, verification, and cleanup. Understanding this lifecycle helps you integrate Turnstile effectively in your React applications.
Script injection happens once per page load, not per component instance
Multiple Turnstile widgets on the same page share the same script
The script state is tracked globally via turnstileState
injectScript={false} bypasses automatic injection for manual control
If you have multiple Turnstile widgets on the same page, only the first one to mount will inject the script. All others will wait for the shared script to load.
The onWidgetLoad callback fires immediately after successful render:
<Turnstile siteKey="your-site-key" onWidgetLoad={(widgetId) => { console.log('Widget rendered with ID:', widgetId) // Widget is now visible and interactive }}/>
After onSuccess, the token is valid for a limited time (typically 5 minutes).
const turnstileRef = useRef<TurnstileInstance>(null)// Check if token is expiredconst isExpired = turnstileRef.current?.isExpired()// Get current tokenconst token = turnstileRef.current?.getResponse()
Expired Token
When a token expires, onExpire is called:
<Turnstile siteKey="your-site-key" onExpire={(token) => { console.log('Token expired:', token) // Widget will auto-refresh based on refreshExpired option }} options={{ refreshExpired: 'auto' // or 'manual' or 'never' }}/>
Error State
If verification fails or a network error occurs:
<Turnstile siteKey="your-site-key" onError={(error) => { console.error('Error code:', error) // Handle error based on code // See: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/ }} options={{ retry: 'auto' // Allow user to retry }}/>
Timeout
If the widget times out:
<Turnstile siteKey="your-site-key" onTimeout={() => { console.warn('Widget timed out') // Widget will auto-refresh based on refreshTimeout option }} options={{ refreshTimeout: 'auto' }}/>
By default, callbacks are stable and don’t cause re-renders:
// Callbacks are stored in refs and don't trigger re-rendersconst callbacksRef = useRef({ onSuccess, onError, onExpire, // ... other callbacks})// Updated on every render without causing widget re-renderuseEffect(() => { if (!rerenderOnCallbackChange) { callbacksRef.current = { onSuccess, onError, // ... } }})
Performance: Only use rerenderOnCallbackChange={true} when you need the widget to re-render based on callback changes. Always wrap callbacks in useCallback to prevent unnecessary re-renders.