import { useCallback, useEffect, useReducer } from "react";
type State = { secondsLeft: number; isRunning: boolean };
type Action =
| { type: "start"; seconds: number }
| { type: "tick" }
| { type: "reset" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "start":
return { secondsLeft: action.seconds, isRunning: action.seconds > 0 };
case "tick": {
const next = Math.max(0, state.secondsLeft - 1);
return { secondsLeft: next, isRunning: next > 0 };
}
case "reset":
return { secondsLeft: 0, isRunning: false };
default:
return state;
}
}
export function useCountdown() {
const [state, dispatch] = useReducer(reducer, {
secondsLeft: 0,
isRunning: false,
});
useEffect(() => {
if (!state.isRunning) return;
const id = setInterval(() => dispatch({ type: "tick" }), 1000);
return () => clearInterval(id);
}, [state.isRunning]);
const start = useCallback((seconds: number) => {
dispatch({ type: "start", seconds });
}, []);
const reset = useCallback(() => dispatch({ type: "reset" }), []);
return {
remaining: state.secondsLeft,
canResend: !state.isRunning,
start,
reset,
};
}
import { useState } from "react";
import { useCountdown } from "./useCountdown";
type Props = {
onResend: () => Promise<void>;
cooldownSeconds?: number;
};
export function ResendCodeButton({ onResend, cooldownSeconds = 30 }: Props) {
const { remaining, canResend, start } = useCountdown();
const [error, setError] = useState<string | null>(null);
async function handleResend() {
if (!canResend) return;
setError(null);
try {
await onResend();
} catch (err) {
setError("Could not resend the code. Try again.");
} finally {
start(cooldownSeconds);
}
}
return (
<div className="resend">
<button type="button" onClick={handleResend} disabled={!canResend}>
{canResend ? "Resend code" : `Resend in ${remaining}s`}
</button>
{error ? <p role="alert" className="resend__error">{error}</p> : null}
</div>
);
}
import { useCallback } from "react";
import { ResendCodeButton } from "./ResendCodeButton";
type Props = { email: string };
export function VerifyCodeScreen({ email }: Props) {
const requestNewCode = useCallback(async () => {
const res = await fetch("/api/auth/resend-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) throw new Error(`resend failed: ${res.status}`);
}, [email]);
return (
<section className="verify">
<h1>Enter the 6-digit code</h1>
<p>We sent a code to {email}.</p>
<input inputMode="numeric" maxLength={6} aria-label="verification code" />
<ResendCodeButton onResend={requestNewCode} cooldownSeconds={30} />
</section>
);
}
This snippet shows the canonical way to build a resend cooldown for a one-time-code screen in React: a button that becomes disabled for a fixed number of seconds after each send, counting down live and re-enabling when it hits zero. The interesting part is not the visuals but the interval lifecycle, which is where naive implementations leak timers or capture stale state.
In useCountdown hook, the countdown is expressed as a useReducer state machine rather than a raw useState number. The reducer has three actions — start, tick, and reset — so the interval callback never has to read the current value to decide the next one; it just dispatches tick, and the reducer computes Math.max(0, secondsLeft - 1). This is what avoids stale-closure bugs: because dispatch is stable and tick needs no arguments, the effect can depend only on isRunning and still always operate on fresh state. The useEffect that owns the setInterval returns a cleanup function that calls clearInterval, so every re-run and every unmount tears down the previous timer before a new one starts. Without that cleanup, toggling the countdown would stack multiple intervals and the number would race downward twice as fast.
The effect early-returns when !isRunning, meaning no interval exists at rest — the timer only lives while it is actually needed. When secondsLeft reaches 0 the reducer flips isRunning to false, the effect re-runs, sees the flag is off, and cleans up. The hook exposes start, remaining, and a derived canResend boolean so callers never touch the reducer directly.
In ResendCodeButton component, the hook drives a single button. handleResend guards on canResend, awaits the injected onResend callback, then calls start(30) to arm the cooldown; wrapping the send in try/finally guarantees the countdown starts even if the request throws. The label switches between an actionable Resend code and a passive Resend in {remaining}s.
The main pitfall this pattern addresses is treating intervals as fire-and-forget. Reaching for a reducer plus disciplined useEffect cleanup keeps the timer count at exactly one, survives fast re-renders and unmounts, and keeps the UI a pure function of state. The trade-off is a little more ceremony than a bare setInterval, which pays off the moment the component is mounted and unmounted repeatedly, as in a modal.
Related snips
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"
const application = Application.start()
application.debug = false
Disable submit button while Turbo form is submitting
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
delay: { type: Number, default: 800 },
Stimulus: autosave draft with Turbo-friendly requests
Share this code
Here's the card — post it anywhere.