ReactReact 기초 · 8기초

커스텀 훅 — 로직 재사용과 추상화

React커스텀훅useLocalStorageuseDebounce재사용

커스텀 훅이란?

use로 시작하는 함수로, React 훅을 내부에서 사용하는 재사용 가능한 로직 단위입니다.


useLocalStorage

import { useState } from "react";

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

    function setValue(value: T | ((prev: T) => T)) {
        const newValue = value instanceof Function ? value(stored) : value;
        setStored(newValue);
        localStorage.setItem(key, JSON.stringify(newValue));
    }

    function removeValue() {
        setStored(initialValue);
        localStorage.removeItem(key);
    }

    return [stored, setValue, removeValue] as const;
}

// 사용
function Settings() {
    const [theme, setTheme] = useLocalStorage("theme", "light");
    const [language, setLanguage] = useLocalStorage("lang", "ko");

    return (
        <div>
            <button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
                테마: {theme}
            </button>
        </div>
    );
}

useDebounce

입력값이 멈춘 후 일정 시간이 지나야 값이 업데이트됩니다.

import { useState, useEffect } from "react";

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

    useEffect(() => {
        const timer = setTimeout(() => setDebounced(value), delay);
        return () => clearTimeout(timer);
    }, [value, delay]);

    return debounced;
}

// 사용: 검색 API를 타이핑마다 호출하지 않고 멈추면 호출
function SearchBox() {
    const [query, setQuery] = useState("");
    const debouncedQuery = useDebounce(query, 300);

    useEffect(() => {
        if (debouncedQuery) {
            fetch(`/api/search?q=${debouncedQuery}`);
        }
    }, [debouncedQuery]);

    return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

useFetch

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

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

    useEffect(() => {
        let cancelled = false;

        setState({ data: null, loading: true, error: null });

        fetch(url)
            .then(r => {
                if (!r.ok) throw new Error(`HTTP ${r.status}`);
                return r.json() as Promise<T>;
            })
            .then(data => { if (!cancelled) setState({ data, loading: false, error: null }); })
            .catch(error => { if (!cancelled) setState({ data: null, loading: false, error }); });

        return () => { cancelled = true; };
    }, [url]);

    return state;
}

// 사용
function UserList() {
    const { data: users, loading, error } = useFetch<User[]>("/api/users");

    if (loading) return <Spinner />;
    if (error) return <ErrorMessage error={error} />;
    return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

useToggle

function useToggle(initial = false) {
    const [value, setValue] = useState(initial);
    const toggle = useCallback(() => setValue(v => !v), []);
    const setTrue = useCallback(() => setValue(true), []);
    const setFalse = useCallback(() => setValue(false), []);
    return [value, toggle, setTrue, setFalse] as const;
}

// 사용
function Modal() {
    const [isOpen, toggleModal, openModal, closeModal] = useToggle();

    return (
        <>
            <button onClick={openModal}>열기</button>
            {isOpen && (
                <div className="modal">
                    <button onClick={closeModal}>닫기</button>
                </div>
            )}
        </>
    );
}

useWindowSize

function useWindowSize() {
    const [size, setSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight,
    });

    useEffect(() => {
        const handler = () => setSize({
            width: window.innerWidth,
            height: window.innerHeight,
        });
        window.addEventListener("resize", handler);
        return () => window.removeEventListener("resize", handler);
    }, []);

    return size;
}

// 사용
function ResponsiveComponent() {
    const { width } = useWindowSize();
    return <div>{width < 768 ? <MobileView /> : <DesktopView />}</div>;
}

커스텀 훅 설계 원칙

flowchart TB
    subgraph PRINCIPLES["좋은 커스텀 훅"]
        P1["단일 책임\n하나의 일에 집중"]
        P2["테스트 가능\n순수 로직 분리"]
        P3["재사용 가능\n컴포넌트와 분리"]
        P4["use 접두사\nHook 규칙 적용"]
    end

정리

커스텀 훅을 만들 때:

  1. 컴포넌트 여러 곳에서 같은 로직이 반복되면 훅으로 추출
  2. use 접두사 필수 (React 훅 규칙 적용)
  3. 상태와 로직만 반환, UI는 컴포넌트가 담당

다음 편에서는 React Router — 싱글페이지 앱에서 페이지 라우팅을 구현하는 방법을 배웁니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.