React Hook 커스텀 패턴 — 실무에서 자주 쓰는 10가지

게시일: 2025년 5월 20일 · 16분 읽기

매번 새로 만들다가 결국 사내 hook 라이브러리를 만들었다. 이 10개 hook만 있어도 대부분의 프로젝트를 커버할 수 있다.

1. useDebounce

function useDebounce<T>(value: T, delay: number = 500): T {
    const [debouncedValue, setDebouncedValue] = useState(value);

    useEffect(() => {
        const timer = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        return () => clearTimeout(timer);
    }, [value, delay]);

    return debouncedValue;
}

2. useLocalStorage

function useLocalStorage<T>(key: string, initialValue: T) {
    const [storedValue, setStoredValue] = useState<T>(() => {
        try {
            const item = window.localStorage.getItem(key);
            return item ? JSON.parse(item) : initialValue;
        } catch {
            return initialValue;
        }
    });

    const setValue = (value: T | ((val: T) => T)) => {
        try {
            const valueToStore = value instanceof Function ? value(storedValue) : value;
            setStoredValue(valueToStore);
            window.localStorage.setItem(key, JSON.stringify(valueToStore));
        } catch (error) {
            console.error(error);
        }
    };

    return [storedValue, setValue] as const;
}

// 사용
function App() {
    const [theme, setTheme] = useLocalStorage('theme', 'light');
    return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>Toggle</button>;
}

3. useMediaQuery

function useMediaQuery(query: string): boolean {
    const [matches, setMatches] = useState(false);

    useEffect(() => {
        const mediaQuery = window.matchMedia(query);
        setMatches(mediaQuery.matches);

        const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
        mediaQuery.addEventListener('change', handler);

        return () => mediaQuery.removeEventListener('change', handler);
    }, [query]);

    return matches;
}

// 사용
function ResponsiveComponent() {
    const isMobile = useMediaQuery('(max-width: 768px)');
    return isMobile ? <MobileView /> : <DesktopView />;
}

4. usePrevious

function usePrevious<T>(value: T): T | undefined {
    const ref = useRef<T>();

    useEffect(() => {
        ref.current = value;
    }, [value]);

    return ref.current;
}

5. useIntersectionObserver

function useIntersectionObserver(
    ref: RefObject<HTMLElement>,
    options: IntersectionObserverInit = {}
): IntersectionObserverEntry | null {
    const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);

    useEffect(() => {
        if (!ref.current) return;

        const observer = new IntersectionObserver(([entry]) => {
            setEntry(entry);
        }, options);

        observer.observe(ref.current);

        return () => observer.disconnect();
    }, [ref, options]);

    return entry;
}

// 사용: 이미지 lazy loading
function LazyLoadImage() {
    const ref = useRef<HTMLImageElement>(null);
    const entry = useIntersectionObserver(ref);

    return (
        <img
            ref={ref}
            src={entry?.isIntersecting ? 'image.jpg' : 'placeholder.jpg'}
        />
    );
}

6. useFetch

interface UseFetchState<T> {
    data: T | null;
    loading: boolean;
    error: Error | null;
}

function useFetch<T>(url: string): UseFetchState<T> {
    const [state, setState] = useState<UseFetchState<T>>({
        data: null,
        loading: true,
        error: null,
    });

    useEffect(() => {
        const controller = new AbortController();

        (async () => {
            try {
                const response = await fetch(url, { signal: controller.signal });
                const data = await response.json();
                setState({ data, loading: false, error: null });
            } catch (error) {
                setState({ data: null, loading: false, error: error as Error });
            }
        })();

        return () => controller.abort();
    }, [url]);

    return state;
}

// 사용
function UserProfile() {
    const { data, loading, error } = useFetch('/api/user/123');

    if (loading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;

    return <div>{data?.name}</div>;
}

추가 패턴

다른 유용한 hook들:

Hook 라이브러리화

이 hook들을 npm 패키지로 만들어 팀이 공유할 수 있다:

// package.json
{
    "name": "@company/react-hooks",
    "version": "1.0.0",
    "main": "dist/index.js",
    "types": "dist/index.d.ts"
}

// index.ts
export { useDebounce } from './useDebounce';
export { useLocalStorage } from './useLocalStorage';
export { useMediaQuery } from './useMediaQuery';
export { usePrevious } from './usePrevious';
export { useIntersectionObserver } from './useIntersectionObserver';
export { useFetch } from './useFetch';

모든 프로젝트에서:

import { useDebounce, useLocalStorage, useFetch } from '@company/react-hooks';

결론

이 10개 hook만 있어도 대부분의 React 프로젝트를 커버할 수 있다. 매번 새로 만드는 대신, 라이브러리로 만들어 팀이 공유하면 개발 속도가 크게 올라간다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.