All files / use-timeout/src useTimeout.ts

100% Statements 32/32
100% Branches 10/10
100% Functions 9/9
100% Lines 32/32

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175                                                                                                                                                                                                      91x   91x 91x     91x 34x       91x 76x 20x 20x         91x 12x 12x       91x     35x     35x   35x   35x 15x 15x 15x             91x 7x 1x   6x       91x   35x 6x 6x       29x     29x 29x       91x            
import { useCallback, useEffect, useRef, useState } from "react";
 
/**
 * Delay type for useTimeout
 * - number: delay in milliseconds
 * - null | undefined: disable timer
 */
export type TimeoutDelay = number | null | undefined;
 
/**
 * Callback type for useTimeout
 */
export type UseTimeoutCallback = () => void;
 
/**
 * Return type for useTimeout hook
 */
export interface UseTimeoutReturn {
  /**
   * Reset timer (restart from beginning)
   */
  reset: () => void;
  /**
   * Cancel timer (callback won't execute)
   */
  clear: () => void;
  /**
   * Whether timer is pending
   */
  isPending: boolean;
}
 
/**
 * A hook for declarative setTimeout with automatic cleanup and controls.
 *
 * Provides a safe way to use setTimeout in React components with:
 * - Automatic cleanup on unmount (prevents memory leaks)
 * - Auto-reset when delay changes
 * - Disable timer when delay is null/undefined
 * - Always maintains latest callback reference (prevents stale closure)
 * - isPending state to check timer status
 *
 * @param callback - Function to execute after delay
 * @param delay - Delay in milliseconds, or null/undefined to disable
 * @returns Object containing reset, clear functions and isPending state
 *
 * @example
 * ```tsx
 * // Auto-dismissing toast
 * function Toast({ message }: { message: string }) {
 *   const [show, setShow] = useState(true);
 *
 *   useTimeout(() => {
 *     setShow(false);
 *   }, 3000);
 *
 *   return show ? <div>{message}</div> : null;
 * }
 * ```
 *
 * @example
 * ```tsx
 * // Debounced auto-save with reset
 * function Editor() {
 *   const [content, setContent] = useState("");
 *
 *   const { reset } = useTimeout(() => {
 *     saveToServer(content);
 *   }, 2000);
 *
 *   const handleChange = (value: string) => {
 *     setContent(value);
 *     reset(); // Reset timer on every keystroke
 *   };
 *
 *   return <textarea onChange={(e) => handleChange(e.target.value)} />;
 * }
 * ```
 *
 * @example
 * ```tsx
 * // Conditional execution
 * function SessionTimeout({ isLoggedIn }: { isLoggedIn: boolean }) {
 *   useTimeout(
 *     () => {
 *       logout();
 *       alert("Session expired");
 *     },
 *     isLoggedIn ? 30 * 60 * 1000 : null // Only when logged in
 *   );
 *
 *   return <div>...</div>;
 * }
 * ```
 */
export function useTimeout(
  callback: UseTimeoutCallback,
  delay: TimeoutDelay
): UseTimeoutReturn {
  const [isPending, setIsPending] = useState<boolean>(false);
 
  const timeoutIdRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const callbackRef = useRef<UseTimeoutCallback>(callback);
 
  // Always keep the latest callback reference
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);
 
  // Clear timeout helper
  const clearTimeoutHelper = useCallback(() => {
    if (timeoutIdRef.current !== null) {
      clearTimeout(timeoutIdRef.current);
      timeoutIdRef.current = null;
    }
  }, []);
 
  // Clear function (exposed)
  const clear = useCallback(() => {
    clearTimeoutHelper();
    setIsPending(false);
  }, [clearTimeoutHelper]);
 
  // Set timeout helper
  const setTimeoutHelper = useCallback(
    (delayMs: number) => {
      // Clear existing timeout
      clearTimeoutHelper();
 
      // Normalize delay: treat negative as 0
      const normalizedDelay = Math.max(0, delayMs);
 
      setIsPending(true);
 
      timeoutIdRef.current = setTimeout(() => {
        timeoutIdRef.current = null;
        setIsPending(false);
        callbackRef.current();
      }, normalizedDelay);
    },
    [clearTimeoutHelper]
  );
 
  // Reset function (exposed)
  const reset = useCallback(() => {
    if (delay === null || delay === undefined) {
      return;
    }
    setTimeoutHelper(delay);
  }, [delay, setTimeoutHelper]);
 
  // Effect: setup/cleanup timeout when delay changes
  useEffect(() => {
    // If delay is null or undefined, clear and don't set new timer
    if (delay === null || delay === undefined) {
      clear();
      return;
    }
 
    // Set new timeout
    setTimeoutHelper(delay);
 
    // Cleanup on unmount or delay change
    return () => {
      clearTimeoutHelper();
    };
  }, [delay, setTimeoutHelper, clearTimeoutHelper, clear]);
 
  return {
    reset,
    clear,
    isPending,
  };
}