UNPKG

react-hook-granth

Version:

A collection of custom React hooks for efficient state management and UI logic.

581 lines (561 loc) 19.9 kB
import { useState, useCallback, useEffect, useRef, useMemo, useLayoutEffect } from 'react'; const useCounter = (initialValue) => { const normalizedInitialValue = initialValue !== null && initialValue !== void 0 ? initialValue : 0; const [count, setCount] = useState(normalizedInitialValue); const increment = useCallback(() => setCount((prev) => prev + 1), []); const decrement = useCallback(() => setCount((prev) => prev - 1), []); const reset = useCallback(() => setCount(normalizedInitialValue), [normalizedInitialValue]); return { count, increment, decrement, reset }; }; /** * Custom hook to sync state with localStorage. * @param key - localStorage key * @param initialValue - Initial value or function returning initial value * @returns Tuple of [storedValue, setStoredValue] */ function useLocalStorage(key, initialValue) { const getSavedValue = () => { if (typeof window === 'undefined') return initialValue; try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch (error) { console.error('Error reading from localStorage', error); return initialValue; } }; const [storedValue, setStoredValue] = useState(getSavedValue); useEffect(() => { try { if (typeof window !== 'undefined') { window.localStorage.setItem(key, JSON.stringify(storedValue)); } } catch (error) { console.error('Error writing to localStorage', error); } }, [key, storedValue]); return [storedValue, setStoredValue]; } /** * Sync state with sessionStorage. * @param key - sessionStorage key * @param initialValue - Initial value * @returns Tuple of [value, setValue] */ function useSessionStorage(key, initialValue) { const getValue = () => { const stored = sessionStorage.getItem(key); return stored ? JSON.parse(stored) : initialValue; }; const [value, setValue] = useState(getValue); useEffect(() => { sessionStorage.setItem(key, JSON.stringify(value)); }, [key, value]); return [value, setValue]; } /** * Store the previous value of a state or prop. * @param value - The current value * @returns The previous value */ function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }, [value]); return ref.current; } // src/hooks/useDebounce.ts function useDebounce(value, delay = 300, options = {}) { const { leading = false, trailing = true, maxWait, onDebounce, onCancel, } = options; const [debouncedValue, setDebouncedValue] = useState(value); const [isPending, setIsPending] = useState(false); const timeoutRef = useRef(null); const maxTimeoutRef = useRef(null); const previousValueRef = useRef(value); const leadingCalledRef = useRef(false); const maxWaitStartTimeRef = useRef(null); // Track when maxWait sequence started const clearTimeouts = useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } if (maxTimeoutRef.current) { clearTimeout(maxTimeoutRef.current); maxTimeoutRef.current = null; } }, []); const updateValue = useCallback((newValue, source = 'debounce') => { setDebouncedValue(newValue); setIsPending(false); clearTimeouts(); if (source === 'debounce' || source === 'maxwait') { onDebounce === null || onDebounce === void 0 ? void 0 : onDebounce(newValue); } previousValueRef.current = newValue; leadingCalledRef.current = false; maxWaitStartTimeRef.current = null; // Reset maxWait sequence }, [onDebounce, clearTimeouts]); const cancel = useCallback(() => { clearTimeouts(); setIsPending(false); leadingCalledRef.current = false; maxWaitStartTimeRef.current = null; onCancel === null || onCancel === void 0 ? void 0 : onCancel(); }, [clearTimeouts, onCancel]); const flush = useCallback(() => { if (isPending && timeoutRef.current) { updateValue(value, 'flush'); } }, [isPending, value, updateValue]); useEffect(() => { // If value hasn't changed, do nothing if (Object.is(previousValueRef.current, value)) { return; } // Initialize maxWait start time on first change in sequence if (maxWaitStartTimeRef.current === null) { maxWaitStartTimeRef.current = Date.now(); } // Clear existing regular timeout (but preserve maxTimeout logic) if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } // Leading edge execution if (leading && !leadingCalledRef.current) { setDebouncedValue(value); leadingCalledRef.current = true; if (!trailing) { // Leading only - no pending state setIsPending(false); previousValueRef.current = value; onDebounce === null || onDebounce === void 0 ? void 0 : onDebounce(value); maxWaitStartTimeRef.current = null; return; } else { // Leading + trailing - still pending for trailing execution setIsPending(true); } } else { setIsPending(true); } // Set up trailing edge execution if (trailing) { timeoutRef.current = setTimeout(() => { updateValue(value, 'debounce'); }, delay); } // Set up max wait timeout (only if not already set) if (maxWait && !maxTimeoutRef.current) { const elapsed = Date.now() - (maxWaitStartTimeRef.current || 0); const remainingMaxWait = maxWait - elapsed; if (remainingMaxWait <= 0) { // MaxWait time has already passed, execute immediately updateValue(value, 'maxwait'); return; } maxTimeoutRef.current = setTimeout(() => { updateValue(value, 'maxwait'); }, remainingMaxWait); } }, [value, delay, leading, trailing, maxWait, updateValue]); // Reset leading flag when options change useEffect(() => { leadingCalledRef.current = false; maxWaitStartTimeRef.current = null; }, [leading, trailing, delay]); return { debouncedValue, cancel, flush, isPending, }; } /** * Throttle a function to only run once every specified delay. * @param callback - The function to throttle * @param delay - Delay in milliseconds * @returns Throttled version of the callback */ function useThrottle(callback, delay) { const lastCallRef = useRef(0); const throttledCallback = useCallback((...args) => { const now = new Date().getTime(); if (now - lastCallRef.current >= delay) { callback(...args); lastCallRef.current = now; } }, [callback, delay]); return throttledCallback; } /** * Run a function after a delay. Supports cancel and reset. * @param callback - Function to run after delay * @param delay - Delay in milliseconds * @returns Object with clear and reset functions */ function useTimeout(callback, delay) { const timeoutRef = useRef(null); const savedCallback = useRef(callback); // Update the latest callback if it changes useEffect(() => { savedCallback.current = callback; }, [callback]); const clear = useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } }, []); const reset = useCallback(() => { clear(); timeoutRef.current = setTimeout(() => { savedCallback.current(); }, delay); }, [clear, delay]); useEffect(() => { reset(); // start the timeout return clear; // cleanup on unmount or delay change }, [delay, reset, clear]); return { clear, reset }; } /** * Detect if user is idle after a given timeout. * @param timeout - Timeout in milliseconds (default: 3000) * @returns True if user is idle */ function useIdle(timeout = 3000) { const [idle, setIdle] = useState(false); useEffect(() => { let timer = null; const reset = () => { if (timer) clearTimeout(timer); setIdle(false); timer = setTimeout(() => setIdle(true), timeout); }; const events = [ 'mousemove', 'mousedown', 'keydown', 'scroll', 'touchstart', ]; events.forEach((e) => window.addEventListener(e, reset)); reset(); // start the timer return () => { if (timer) clearTimeout(timer); events.forEach((e) => window.removeEventListener(e, reset)); }; }, [timeout]); return idle; } /** * Hook to detect clicks outside of a specified element. * @param handler - Callback to run on outside click. * @returns Ref to attach to your target element. */ function useClickOutside(handler) { const ref = useRef(null); useEffect(() => { const listener = (event) => { // Type guard to ensure event.target exists and is a Node if (!ref.current || !event.target || !(event.target instanceof Node) || ref.current.contains(event.target)) { return; } handler(event); }; document.addEventListener('mousedown', listener); document.addEventListener('touchstart', listener); return () => { document.removeEventListener('mousedown', listener); document.removeEventListener('touchstart', listener); }; }, [handler]); return ref; } /** * Custom hook to copy text to clipboard with enhanced features. * @param options - Configuration options for the hook * @returns Object with isCopied state, copy function, and reset function */ function useCopyToClipboard(options = {}) { const { resetTime = 2000, onSuccess, onError } = options; const [isCopied, setIsCopied] = useState(false); const timeoutRef = useRef(null); const reset = useCallback(() => { setIsCopied(false); if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } }, []); const copy = useCallback(async (text) => { if (!(navigator === null || navigator === void 0 ? void 0 : navigator.clipboard)) { const error = new Error('Clipboard API not supported'); console.warn(error.message); onError === null || onError === void 0 ? void 0 : onError(error); return false; } try { await navigator.clipboard.writeText(text); setIsCopied(true); onSuccess === null || onSuccess === void 0 ? void 0 : onSuccess(); // Clear existing timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current); } // Set new timeout timeoutRef.current = setTimeout(() => { setIsCopied(false); timeoutRef.current = null; }, resetTime); return true; } catch (error) { const copyError = error instanceof Error ? error : new Error('Unknown error occurred'); console.error('Copy failed:', copyError); onError === null || onError === void 0 ? void 0 : onError(copyError); setIsCopied(false); return false; } }, [resetTime, onSuccess, onError]); return { isCopied, copy, reset }; } /** * Track the current window size. * @returns The current window dimensions */ function useWindowSize() { const [size, setSize] = useState(() => { if (typeof window === 'undefined') { return { width: 0, height: 0 }; } return { width: window.innerWidth, height: window.innerHeight, }; }); useEffect(() => { const updateSize = () => { setSize({ width: window.innerWidth, height: window.innerHeight }); }; window.addEventListener('resize', updateSize); return () => window.removeEventListener('resize', updateSize); }, []); return size; } /** * Custom hook to match a CSS media query. * @param query - The media query string * @returns Whether the query currently matches */ function useMediaQuery(query) { const [matches, setMatches] = useState(() => { if (typeof window === 'undefined') { return false; } return window.matchMedia(query).matches; }); useEffect(() => { if (typeof window === 'undefined') { return; } const mediaQueryList = window.matchMedia(query); const listener = (event) => setMatches(event.matches); mediaQueryList.addEventListener('change', listener); // Initial check setMatches(mediaQueryList.matches); return () => { mediaQueryList.removeEventListener('change', listener); }; }, [query]); return matches; } /** * Custom hook to track online/offline status. * @returns True if online, false if offline */ function useOnlineStatus() { const [isOnline, setIsOnline] = useState(() => { if (typeof navigator === 'undefined') { return true; // Assume online in SSR } return navigator.onLine; }); useEffect(() => { if (typeof window === 'undefined') { return; } const goOnline = () => setIsOnline(true); const goOffline = () => setIsOnline(false); window.addEventListener('online', goOnline); window.addEventListener('offline', goOffline); // Clean up return () => { window.removeEventListener('online', goOnline); window.removeEventListener('offline', goOffline); }; }, []); return isOnline; } /** * Track the network speed and connection type. * @returns Network connection details */ function useNetworkSpeed() { const [networkSpeed, setNetworkSpeed] = useState({ connectionType: 'unknown', downlink: 0, }); useEffect(() => { const updateNetworkSpeed = () => { if ('connection' in navigator && navigator.connection) { const { effectiveType, downlink } = navigator.connection; setNetworkSpeed({ connectionType: effectiveType, downlink }); } }; updateNetworkSpeed(); // Initial update window.addEventListener('online', updateNetworkSpeed); window.addEventListener('offline', updateNetworkSpeed); return () => { window.removeEventListener('online', updateNetworkSpeed); window.removeEventListener('offline', updateNetworkSpeed); }; }, []); return networkSpeed; } /** * Track the scroll position of the window. * @returns The current scroll position (x, y) */ function useScrollPosition() { const [position, setPosition] = useState(() => { if (typeof window === 'undefined') { return { x: 0, y: 0 }; } return { x: window.scrollX, y: window.scrollY, }; }); useEffect(() => { if (typeof window === 'undefined') { return; } const handleScroll = () => { setPosition({ x: window.scrollX, y: window.scrollY }); }; window.addEventListener('scroll', handleScroll); return () => { window.removeEventListener('scroll', handleScroll); }; }, []); return position; } /** * useScrollIntoView Hook * * This hook automatically scrolls an element into view when any specified trigger in an array changes. * It also provides manual control, improved error handling, and optimizations. * * @param ref - The React ref object pointing to the target element. * @param triggers - An array of state variables that trigger the scroll effect when any of them change. * @param delay - The delay (in milliseconds) before scrolling occurs. * @param options - ScrollIntoView options. * @param onScrollComplete - A callback function executed after scrolling is completed. * * @returns An object containing: * - hasScrolled: Indicates whether the scrolling has completed. * - error: An error object if the ref is invalid. * - scrollToElement: A function to manually trigger scrolling. */ const useScrollIntoView = (ref, triggers = [], delay = 100, options = { behavior: 'smooth', block: 'start', inline: 'nearest', }, onScrollComplete = () => { }) => { const [hasScrolled, setHasScrolled] = useState(false); const [error, setError] = useState(null); const timeoutRef = useRef(null); const validateRef = useCallback(() => { if (!ref || !ref.current) { return { message: 'Invalid ref provided or element not found.' }; } if (!(ref.current instanceof HTMLElement)) { return { message: 'Ref is not attached to a valid DOM element.' }; } return null; }, [ref]); const scrollToElement = useCallback(() => { const validationError = validateRef(); if (validationError) { setError(validationError); return; } setError(null); setHasScrolled(false); if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { var _a; (_a = ref.current) === null || _a === void 0 ? void 0 : _a.scrollIntoView(options); setHasScrolled(true); onScrollComplete(); }, delay); }, [validateRef, delay, options, onScrollComplete, ref]); const triggerDeps = useMemo(() => [...triggers], [triggers]); useEffect(() => { if (!triggers.some(Boolean)) return; scrollToElement(); return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, triggerDeps); return { hasScrolled, error, scrollToElement }; }; /** * Set the document title dynamically. * @param title - The title to set for the document. */ function useDocumentTitle(title) { useEffect(() => { document.title = title; }, [title]); } /** * Measure the size of a DOM element. * @returns Tuple of [ref, bounds] */ function useMeasure() { const ref = useRef(null); const [bounds, setBounds] = useState({ width: 0, height: 0 }); const measure = useCallback(() => { if (ref.current) { const { width, height } = ref.current.getBoundingClientRect(); setBounds({ width, height }); } }, []); useLayoutEffect(() => { measure(); window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); }, [measure]); return [ref, bounds]; } export { useClickOutside, useCopyToClipboard, useCounter, useDebounce, useDocumentTitle, useIdle, useLocalStorage, useMeasure, useMediaQuery, useNetworkSpeed, useOnlineStatus, usePrevious, useScrollIntoView, useScrollPosition, useSessionStorage, useThrottle, useTimeout, useWindowSize }; //# sourceMappingURL=index.esm.js.map