| name | react-useref-duplicate-prevention-race |
| description | Fix React duplicate detection race conditions using useRef for synchronous checks.
Use when: (1) Rapid successive function calls bypass duplicate/spam detection despite
state-based checking logic, (2) Rate limiting or cooldown logic fails when triggered
multiple times quickly, (3) Duplicate items appear in lists despite deduplication code,
(4) State-based guards don't prevent re-entry in event handlers. Solves race condition
where multiple calls read the same stale state value before any setState completes.
Covers React hooks patterns with useRef, useState, useCallback for duplicate prevention,
spam protection, rate limiting, and synchronous guard checks.
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-02-02T00:00:00.000Z" |
React useRef for Duplicate Prevention Race Conditions
Problem
When implementing duplicate detection, spam prevention, or rate limiting in React using state, rapid successive function calls can bypass the checks due to React's asynchronous state updates. All calls read the same stale state value before any setState completes, allowing duplicates through.
Common Scenario: Alert/notification system where rapid form validation errors trigger multiple identical alerts despite duplicate detection logic.
Context / Trigger Conditions
Use this pattern when you encounter:
-
Duplicate Detection Failures:
- Multiple identical alerts/notifications appear despite deduplication logic
- Spam prevention allows duplicates when triggered rapidly
- List deduplication fails for items added in quick succession
-
State-Based Checks Failing:
- Code like
if (recentItems.has(item)) return; doesn't prevent duplicates
- Cooldown/rate limiting logic bypassed by rapid clicks
- Guard clauses using state don't prevent re-entry
-
Symptoms:
- Problem only appears with rapid successive calls (< 100ms apart)
- Single slow calls work correctly
- Adding
console.log shows all calls see the same state value
- Problem worse in production (React batches updates more aggressively)
-
Code Patterns That Fail:
const [recentItems, setRecentItems] = useState(new Map());
const addItem = useCallback((item) => {
if (recentItems.has(item.id)) return;
setRecentItems(prev => {
const next = new Map(prev);
next.set(item.id, Date.now());
return next;
});
}, [recentItems]);
Solution
Use useRef for synchronous duplicate checking while keeping state for rendering needs:
Step 1: Add ref for synchronous tracking
import { useRef, useState, useCallback } from 'react';
const [items, setItems] = useState([]);
const recentItemsRef = useRef(new Map());
Step 2: Check and update ref synchronously
const addItem = useCallback((item) => {
const now = Date.now();
const lastSeen = recentItemsRef.current.get(item.id);
if (lastSeen && now - lastSeen < 5000) {
return;
}
recentItemsRef.current.set(item.id, now);
for (const [id, timestamp] of recentItemsRef.current.entries()) {
if (now - timestamp > 5000) {
recentItemsRef.current.delete(id);
}
}
setItems(prev => [...prev, item]);
}, []);
Step 3: Remove state dependency from useCallback
The key insight: refs don't need to be in the dependency array because they're always the same object reference. This prevents recreating the callback on every state change.
Complete Example
import React, { useRef, useState, useCallback } from 'react';
const DUPLICATE_WINDOW_MS = 5000;
function AlertProvider({ children }) {
const [alerts, setAlerts] = useState([]);
const recentAlertsRef = useRef(new Map());
const addAlert = useCallback((alert) => {
const normalized = `${alert.title}:${alert.message}`.toLowerCase().trim();
const now = Date.now();
const lastShown = recentAlertsRef.current.get(normalized);
if (lastShown && now - lastShown < DUPLICATE_WINDOW_MS) {
return '';
}
const id = `alert-${now}-${Math.random().toString(36).substr(2, 9)}`;
recentAlertsRef.current.set(normalized, now);
for (const [key, timestamp] of recentAlertsRef.current.entries()) {
if (now - timestamp > DUPLICATE_WINDOW_MS) {
recentAlertsRef.current.delete(key);
}
}
setAlerts(prev => [...prev, { ...alert, id, timestamp: now }]);
return id;
}, []);
return (
<AlertContext.Provider value={{ alerts, addAlert }}>
{children}
</AlertContext.Provider>
);
}
Verification
Test the fix:
-
Rapid Click Test:
for (let i = 0; i < 10; i++) {
addAlert({ title: 'Test', message: 'Same message' });
}
-
Console Verification:
const addItem = useCallback((item) => {
console.log('Ref check:', recentItemsRef.current.has(item.id));
if (recentItemsRef.current.has(item.id)) return;
recentItemsRef.current.set(item.id, Date.now());
}, []);
-
Success Criteria:
- Rapid successive calls with same data create only one item
- Different data creates multiple items correctly
- No console warnings about stale closures
- useCallback has no or minimal dependencies
Why This Works
The Race Condition Explained:
With useRef:
Key Differences:
useState: Asynchronous updates, batched by React
useRef: Synchronous read/write, immediate mutation
- Refs bypass React's render cycle entirely for the duplicate check
- State still used for rendering (separation of concerns)
Notes
When to Use This Pattern
โ
Good use cases:
- Duplicate detection (alerts, notifications, toasts)
- Rate limiting (API calls, button clicks)
- Spam prevention (form submissions)
- Cooldown timers (game actions, animations)
- Request deduplication (cache keys, fetch guards)
โ Don't use for:
- Values that affect rendering (use state instead)
- Complex business logic (use state + proper async handling)
- Data that needs to persist across component unmounts (use external storage)
Performance Considerations
- Refs don't trigger re-renders when mutated (good for frequent updates)
- Map/Set cleanup during each call is O(n) - for large Maps, consider periodic cleanup:
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
for (const [key, timestamp] of recentItemsRef.current.entries()) {
if (now - timestamp > DUPLICATE_WINDOW_MS) {
recentItemsRef.current.delete(key);
}
}
}, 10000);
return () => clearInterval(interval);
}, []);
Related Patterns
-
AbortController Pattern (for fetch requests):
const abortRef = useRef(null);
useEffect(() => {
abortRef.current?.abort();
abortRef.current = new AbortController();
fetch('/api', { signal: abortRef.current.signal })
.then(handleResponse);
}, [dependency]);
-
Boolean Flag Pattern (for component unmount):
const isMountedRef = useRef(true);
useEffect(() => {
return () => { isMountedRef.current = false; };
}, []);
const fetchData = async () => {
const data = await fetch('/api');
if (isMountedRef.current) {
setData(data);
}
};
-
Debounce/Throttle with useRef:
const lastCallRef = useRef(0);
const throttledFunction = useCallback((arg) => {
const now = Date.now();
if (now - lastCallRef.current < 1000) return;
lastCallRef.current = now;
actualFunction(arg);
}, []);
Common Pitfalls
-
Forgetting to remove state from dependencies:
const addItem = useCallback((item) => {
if (recentItemsRef.current.has(item)) return;
recentItemsRef.current.set(item, Date.now());
setItems(prev => [...prev, item]);
}, [recentItems]);
const addItem = useCallback((item) => {
if (recentItemsRef.current.has(item)) return;
recentItemsRef.current.set(item, Date.now());
setItems(prev => [...prev, item]);
}, []);
-
Using state for the check instead of ref:
const [recentItems, setRecentItems] = useState(new Set());
const addItem = useCallback((item) => {
if (recentItems.has(item)) return;
setRecentItems(prev => new Set(prev).add(item));
}, [recentItems]);
-
Not cleaning up old entries:
recentItemsRef.current.set(item.id, Date.now());
for (const [id, timestamp] of recentItemsRef.current.entries()) {
if (now - timestamp > WINDOW_MS) {
recentItemsRef.current.delete(id);
}
}
References