소개
자주 쓰는 커스텀 훅 소개
Time
useInterval
typescriptimport { useEffect, useRef } from "react"; type ParamOptions = { callback: () => void; delay: number | null; }; export default function useInterval({ callback, delay = 1000 }: ParamOptions) { const savedCallback = useRef(callback); const timerRef = useRef<any>(null); const expectedRef = useRef<number>(0); useEffect(() => { savedCallback.current = callback; }, [callback]); useEffect(() => { if (delay === null) return; expectedRef.current = Date.now() + delay; const tick = () => { const drift = Date.now() - expectedRef.current; savedCallback.current?.(); expectedRef.current += delay; timerRef.current = setTimeout(tick, Math.max(0, delay - drift)); }; timerRef.current = setTimeout(tick, delay); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, [delay]); }
useTimer
typescriptimport { useEffect, useRef, useState } from "react"; type ParamOptions = { autoStart?: boolean; }; export default function useTimer({ autoStart = false }: ParamOptions = {}) { const [time, setTime] = useState(0); const [isRunning, setIsRunning] = useState(false); const startTimeRef = useRef<number | null>(null); const pauseTimeRef = useRef<number>(0); const timerRef = useRef<number | null>(null); const tick = () => { if (!startTimeRef.current) return; const now = Date.now(); const elapsed = now - startTimeRef.current + pauseTimeRef.current; setTime(elapsed); timerRef.current = window.setTimeout(tick, 100); }; const start = () => { if (isRunning) return; startTimeRef.current = Date.now(); pauseTimeRef.current = 0; setIsRunning(true); tick(); }; const pause = () => { if (!isRunning || !startTimeRef.current) return; pauseTimeRef.current += Date.now() - startTimeRef.current; if (timerRef.current) clearTimeout(timerRef.current); setIsRunning(false); }; const resume = () => { if (isRunning) return; startTimeRef.current = Date.now(); setIsRunning(true); tick(); }; const stop = () => { if (timerRef.current) clearTimeout(timerRef.current); startTimeRef.current = null; pauseTimeRef.current = 0; setIsRunning(false); setTime(0); }; const reset = () => { stop(); }; useEffect(() => { // 자동 시작 여부 체크 if (autoStart) start(); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, []); return { time, isRunning, start, pause, resume, stop, reset, }; }
useCountdown
typescriptimport { useEffect, useRef, useState } from "react"; type ParamOptions = { duration: number; // ms onFinish?: () => void; }; export default function useCountdown({ duration, onFinish }: ParamOptions) { const [time, setTime] = useState(duration); const [isRunning, setIsRunning] = useState(false); const startTimeRef = useRef<number | null>(null); const remainingRef = useRef(duration); const timerRef = useRef<any>(null); const tick = () => { if (!startTimeRef.current) return; const elapsed = Date.now() - startTimeRef.current; const remaining = remainingRef.current - elapsed; if (remaining <= 0) { setTime(0); setIsRunning(false); if (timerRef.current) clearTimeout(timerRef.current); onFinish?.(); return; } setTime(remaining); timerRef.current = setTimeout(tick, 100); }; const start = () => { if (isRunning) return; startTimeRef.current = Date.now(); remainingRef.current = duration; setIsRunning(true); tick(); }; const pause = () => { if (!isRunning || !startTimeRef.current) return; const elapsed = Date.now() - startTimeRef.current; remainingRef.current -= elapsed; if (timerRef.current) clearTimeout(timerRef.current); setIsRunning(false); }; const resume = () => { if (isRunning) return; startTimeRef.current = Date.now(); setIsRunning(true); tick(); }; const stop = () => { if (timerRef.current) clearTimeout(timerRef.current); startTimeRef.current = null; remainingRef.current = duration; setTime(duration); setIsRunning(false); }; const reset = () => { stop(); }; useEffect(() => { // 자동 시작하지 않는다. return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, []); return { time, isRunning, start, pause, resume, stop, reset, }; }
useDeadline
typescriptimport { useEffect, useRef, useState } from "react"; type ParamOptions = { target: Date | number; onFinish?: () => void; }; export default function useDeadline({ target, onFinish }: ParamOptions) { const targetTime = typeof target === "number" ? target : target.getTime(); const [time, setTime] = useState(targetTime - Date.now()); const [isRunning, setIsRunning] = useState(false); const timerRef = useRef<any>(null); const tick = () => { const remaining = targetTime - Date.now(); if (remaining <= 0) { setTime(0); setIsRunning(false); if (timerRef.current) clearTimeout(timerRef.current); onFinish?.(); return; } setTime(remaining); timerRef.current = setTimeout(tick, 100); }; const start = () => { if (isRunning) return; setIsRunning(true); tick(); }; useEffect(() => { // 바로 시작 start(); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, []); return { time, }; }
Permissions
usePermissions
typescriptimport { useEffect, useState } from "react"; // type PermissionName = "camera" | "geolocation" | "microphone" | "midi" | "notifications" | "persistent-storage" | "push" | "screen-wake-lock" | "storage-access"; // type PermissionState = "denied" | "granted" | "prompt"; export default function usePermissions({ name, onGranted, onDenied, }: { name: PermissionName; onGranted?: () => void; onDenied?: () => void; }) { const [status, setStatus] = useState(""); useEffect(() => { navigator.permissions.query({ name }).then((permission) => { console.log(permission.state); permission.onchange = () => { setStatus(permission.state); if (permission.state === "granted") onGranted?.(); if (permission.state === "denied") onDenied?.(); }; if (permission.state === "granted") onGranted?.(); if (permission.state === "denied") onDenied?.(); }); }, []); return { status, }; }
useGeolocation
typescriptimport { useState } from "react"; import usePermissions from "./usePermissions"; type Coords = { latitude: number; longitude: number; }; type ParamOptions = { onDenied?: () => void }; export default function useGeolocation({ onDenied }: ParamOptions = {}) { const [coords, setCoords] = useState<Coords>({ latitude: 0, longitude: 0 }); usePermissions({ name: "geolocation", onGranted: () => { navigator?.geolocation?.getCurrentPosition((position) => { const { latitude, longitude } = position.coords; setCoords({ latitude, longitude }); }); }, onDenied, }); return coords; }
useCamera
typescriptimport { useEffect, useRef } from "react"; import usePermissions from "./usePermissions"; type VideoType = { width?: number; height?: number; facingMode?: "user" | "environment"; aspectRatio?: number; }; type AudioType = { echoCancellation?: boolean; noiseSuppression?: boolean; autoGainControl?: boolean; channelCount?: number; sampleRate?: number; sampleSize?: number; }; type MediaType = { video?: boolean | VideoType; audio?: boolean | AudioType; }; type ParamOptions = { options?: MediaType; onDenied?: () => void }; export default function useCamera({ options, onDenied }: ParamOptions = {}) { const stream = useRef<HTMLVideoElement | null>(null); usePermissions({ name: "camera", onGranted: async () => { const media = await navigator.mediaDevices.getUserMedia(options); if (stream.current) { stream.current.srcObject = media; await stream.current.play(); } }, onDenied, }); const capture = () => { if (!stream.current) return; const canvas = document.createElement("canvas"); canvas.width = stream.current?.videoWidth ?? 640; canvas.height = stream.current?.videoHeight ?? 640; const ctx = canvas.getContext("2d"); ctx?.drawImage(stream.current, 0, 0); const image = canvas.toDataURL("image/png"); }; useEffect(() => { return () => { if (!stream.current) return; const media = stream.current?.srcObject; if (media && media instanceof MediaStream) { media.getTracks().forEach((track) => track.stop()); } }; }, []); return { stream, capture, }; }