3 분 소요

개요

커스텀 Hook은 반복되는 상태 로직을 별도 함수로 분리해 재사용하는 패턴입니다. 이름이 반드시 use로 시작해야 하며, 내부에서 다른 Hook을 사용할 수 있습니다.


언제 만드나?

  • 여러 컴포넌트에서 같은 로직이 반복될 때
  • 컴포넌트가 너무 복잡해질 때 (로직과 UI 분리)
  • 테스트하기 쉽게 로직을 분리하고 싶을 때


useFetch - API 데이터 가져오기

// src/hooks/useFetch.ts
import { useState, useEffect } from "react";

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

function useFetch<T>(url: string): FetchState<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;   // 언마운트 후 state 업데이트 방지

    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);
        const res = await fetch(url);
        if (!res.ok) throw new Error(`HTTP 오류: ${res.status}`);
        const json: T = await res.json();
        if (!cancelled) setData(json);
      } catch (err) {
        if (!cancelled) {
          setError(err instanceof Error ? err.message : "오류 발생");
        }
      } finally {
        if (!cancelled) setLoading(false);
      }
    };

    fetchData();

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

  return { data, loading, error };
}

export default useFetch;
// 사용
import useFetch from "./hooks/useFetch";

interface User {
  id: number;
  name: string;
  email: string;
}

function App() {
  const { data: users, loading, error } = useFetch<User[]>(
    "https://jsonplaceholder.typicode.com/users"
  );

  if (loading) return <p>로딩 중...</p>;
  if (error) return <p>오류: {error}</p>;

  return (
    <ul>
      {users?.map((user) => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}


useLocalStorage - 로컬 스토리지 동기화

// src/hooks/useLocalStorage.ts
import { useState } from "react";

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

  const setStoredValue = (newValue: T | ((prev: T) => T)) => {
    try {
      const valueToStore = typeof newValue === "function"
        ? (newValue as (prev: T) => T)(value)
        : newValue;
      setValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (err) {
      console.error("LocalStorage 저장 실패:", err);
    }
  };

  return [value, setStoredValue] as const;
}

export default useLocalStorage;
// 사용
import useLocalStorage from "./hooks/useLocalStorage";

function App() {
  const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
  const [name, setName] = useLocalStorage("userName", "");

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        테마: {theme}
      </button>
    </div>
  );
}


useToggle - 불린 전환

// src/hooks/useToggle.ts
import { useState, useCallback } from "react";

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => setValue((prev) => !prev), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return { value, toggle, setTrue, setFalse };
}

export default useToggle;
// 사용
function App() {
  const modal = useToggle(false);
  const darkMode = useToggle(false);

  return (
    <div>
      <button onClick={modal.toggle}>
        {modal.value ? "모달 닫기" : "모달 열기"}
      </button>
      {modal.value && <div>모달 내용</div>}

      <button onClick={darkMode.toggle}>
        {darkMode.value ? "라이트 모드" : "다크 모드"}
      </button>
    </div>
  );
}


useDebounce - 입력 지연

검색창처럼 입력이 잦을 때 API 호출 횟수를 줄입니다.

// src/hooks/useDebounce.ts
import { useState, useEffect } from "react";

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

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

    return () => clearTimeout(timer);   // 입력이 다시 오면 이전 타이머 취소
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;
// 사용
import { useState } from "react";
import useDebounce from "./hooks/useDebounce";
import useFetch from "./hooks/useFetch";

function SearchBar() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 500);  // 500ms 지연

  const { data, loading } = useFetch<any[]>(
    debouncedQuery
      ? `https://jsonplaceholder.typicode.com/posts?q=${debouncedQuery}`
      : ""
  );

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="검색어 입력 (0.5초 후 자동 검색)"
      />
      {loading && <p>검색 중...</p>}
      <p>결과: {data?.length ?? 0}</p>
    </div>
  );
}


useWindowSize - 화면 크기

// src/hooks/useWindowSize.ts
import { useState, useEffect } from "react";

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

  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };

    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return size;
}

export default useWindowSize;


커스텀 Hook 규칙

  • 이름은 반드시 use로 시작 (React가 Hook임을 인식)
  • 최상위 레벨에서만 호출 (조건문, 반복문 안에서 호출 금지)
  • 로직을 재사용하는 것이지, UI를 포함하지 않음 (JSX 반환 금지 권장)
  • src/hooks/ 폴더에 모아서 관리 (대규모 프로젝트 권장)


관련 링크