typescript 105 lines · 3 tabs

Resend-Code Button With a Countdown Timer Using useEffect Cleanup

Shared by codesnips Aug 2026
3 tabs
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,
  };
}
3 files · typescript Explain with highlit

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

typescript
export type Settled<R> =
  | { status: 'fulfilled'; value: R }
  | { status: 'rejected'; reason: unknown };

export interface ConcurrencyOptions {
  limit: number;

Simple concurrency limiter for batch operations

node concurrency async
by codesnips 2 tabs
typescript
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

react axios api
by Maya Patel 1 tab
typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface FilterState {
  search: string
  category: string | null

Zustand for lightweight state management

react zustand state-management
by Maya Patel 2 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Form Validation Example</title>
  <style>

HTML forms with validation and accessibility

html forms validation
by Alex Chang 1 tab
javascript
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

rails hotwire stimulus
by codesnips 3 tabs
javascript
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

rails stimulus hotwire
by codesnips 3 tabs

Share this code

Here's the card — post it anywhere.

Resend-Code Button With a Countdown Timer Using useEffect Cleanup — share card
Link copied