Подвійний клік, Enter у полі під час повільного запиту, повторна відправка після «зависання» - типові джерела дублікатів замовлень і коментарів. Захист потрібен на кількох рівнях.
1. Інтерфейс - не дати відправити вдруге:
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Оформлюємо...' : 'Оформити'}</button>;
}
З useActionState дії ставляться в чергу й виконуються по черзі, а не паралельно, - результат передбачуваний навіть при повторних натисканнях. Але вони все одно виконаються всі.
2. Своя логіка відправки - прапорець у ref, а не лише стан:
const submitting = useRef(false);
async function handleSubmit(values) {
if (submitting.current) return;
submitting.current = true;
try {
await api.placeOrder(values);
} finally {
submitting.current = false;
}
}
Стан (useState) оновлюється з рендером - два швидкі кліки можуть обидва побачити false. Ref змінюється одразу.
3. Гонитва відповідей - коли новий запит починається до завершення попереднього (пошук, фільтри, автозбереження). Стара відповідь може прийти пізніше й перезаписати нову:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
.then((r) => r.json())
.then(setResults)
.catch((e) => { if (e.name !== 'AbortError') throw e; });
return () => controller.abort(); // скасувати попередній запит
}, [query]);
Або бібліотеки даних (TanStack Query), що розв'язують це самі.
4. Сервер - останній і головний рубіж. Клієнтський захист обходиться повторним запитом, повільною мережею з повтором, двома вкладками:
- ключ ідемпотентності: клієнт генерує
crypto.randomUUID()при відкритті форми й надсилає його з запитом; сервер не створює вдруге замовлення з тим самим ключем; - унікальні обмеження в базі (одна підписка на email);
- блокування (
Cache::lockу Laravel) для операцій, що не повинні виконуватися паралельно.
5. Після успіху - перенаправлення чи скидання форми, щоб «Назад» і повторне натискання не відправили ті самі дані знову.