리액트 커스텀 훅 만들기: useLocalStorage·useFetch 핵심 2가지

리액트 커스텀 훅 만들기: useLocalStorage·useFetch 핵심 2가지

커스텀 훅(Custom Hook)은 컴포넌트 간에 상태 로직을 재사용하는 가장 깔끔한 방법입니다. 이 글에서는 실무에서 특히 많이 쓰는 두 가지 패턴만 선별해 빠르게 알아봅니다.

언제 커스텀 훅을 만들까?

  • 여러 컴포넌트에서 같은 로직을 반복해서 쓸 때 (예: 입력 상태, 저장소 동기화, 패칭)
  • 컴포넌트 파일이 너무 길어질 때 상태 처리만 분리해 가독성·테스트성을 높이고 싶을 때
  • 이벤트 리스너·타이머·네트워크 요청처럼 정리(cleanup)가 필요한 로직을 안전하게 재사용하고 싶을 때

예제 1) useLocalStorage — 상태를 브라우저 저장소와 동기화

상태를 localStorage에 저장해 새로고침/다른 탭에서도 유지합니다. storage 이벤트로 탭 간 동기화도 처리합니다.

코드: src/hooks/useLocalStorage.js

import { useEffect, useState, useCallback } from 'react';

export default function useLocalStorage(key, initialValue) {
  const read = () => {
    if (typeof window === 'undefined') return initialValue;
    try {
      const v = window.localStorage.getItem(key);
      return v !== null ? JSON.parse(v) : initialValue;
    } catch { return initialValue; }
  };

  const [value, setValue] = useState(read);

  useEffect(() => {
    try { window.localStorage.setItem(key, JSON.stringify(value)); } catch {}
  }, [key, value]);

  useEffect(() => {
    const onStorage = (e) => {
      if (e.key === key) {
        try {
          setValue(e.newValue !== null ? JSON.parse(e.newValue) : initialValue);
        } catch {}
      }
    };
    window.addEventListener('storage', onStorage);
    return () => window.removeEventListener('storage', onStorage);
  }, [key, initialValue]);

  const reset = useCallback(() => {
    try { window.localStorage.removeItem(key); } catch {}
    setValue(initialValue);
  }, [key, initialValue]);

  return [value, setValue, reset];
}

데모: src/examples/ThemeSwitcherDemo.jsx

import React from 'react';
import useLocalStorage from '../hooks/useLocalStorage';

export default function ThemeSwitcherDemo() {
  const [theme, setTheme, reset] = useLocalStorage('demo-theme', 'light');
  const toggle = () => setTheme(t => (t === 'light' ? 'dark' : 'light'));

  const styles = theme === 'dark'
    ? { background: '#111', color: '#fff', padding: 12 }
    : { background: '#f7f7f7', color: '#222', padding: 12 };

  return (
    <div style={styles}>
      <h3>useLocalStorage 데모</h3>
      <p>현재 테마: <strong>{theme}</strong></p>
      <button onClick={toggle}>테마 토글</button>
      <button onClick={reset} style={{ marginLeft: 8 }}>리셋</button>
      <p style={{ marginTop: 8 }}>새로고침/다른 탭에서도 값이 유지되는지 확인하세요.</p>
    </div>
  );
}

실행 화면 설명: 현재 테마(light/dark)가 굵게 표시되며, [테마 토글] 클릭 시 즉시 테마가 바뀌고 [리셋]을 누르면 기본값으로 돌아옵니다. 새로고침하거나 다른 탭을 열어도 값이 유지되는 것을 확인할 수 있습니다.

리액트 커스텀 훅 만들기 "useLocalStorage — 상태를 브라우저 저장소와 동기화" 예시 화면

예제 2) useFetch — 안전한 데이터 패칭(요청 취소/무한 루프 방지)

요청 취소(AbortController)와 의존성 안정화를 처리하여 개발 모드에서도 불필요한 반복 요청을 막습니다.

코드: src/hooks/useFetch.js

import { useEffect, useRef, useState, useCallback, useMemo } from 'react';

export default function useFetch(url, options) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(!!url);
  const [error, setError] = useState(null);
  const abortRef = useRef(null);

  const stableOptions = useMemo(() => options ?? undefined, [options]);

  const execute = useCallback(async () => {
    if (!url) return;
    setLoading(true);
    setError(null);

    abortRef.current?.abort();
    const controller = new AbortController();
    abortRef.current = controller;

    try {
      const res = await fetch(url, { ...stableOptions, signal: controller.signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const json = await res.json();
      setData(json);
    } catch (e) {
      if (e.name !== 'AbortError') setError(e);
    } finally {
      setLoading(false);
    }
  }, [url, stableOptions]);

  useEffect(() => {
    execute();
    return () => abortRef.current?.abort();
  }, [execute]);

  return { data, loading, error, refetch: execute };
}

데모: src/examples/UsersFetcherSafe.jsx

import React from 'react';
import useFetch from '../hooks/useFetch';

export default function UsersFetcherSafe() {
  const { data, loading, error, refetch } = useFetch(
    'https://jsonplaceholder.typicode.com/users'
  );

  return (
    <div style={{ padding: 12, border: '1px solid #ddd' }}>
      <h3>useFetch 데모</h3>
      {loading && <p>로딩 중...</p>}
      {error && <p style={{ color: 'crimson' }}>에러: {String(error.message)}</p>}
      {data && (
        <ul>
          {data.map((u) => (
            <li key={u.id}>{u.name} ({u.email})</li>
          ))}
        </ul>
      )}
      <button onClick={refetch} style={{ marginTop: 8 }}>다시 불러오기</button>
    </div>
  );
}

실행 화면 설명: 사용자 목록이 표시되고, [다시 불러오기] 클릭 시 네트워크 요청이 한 번 더 수행되어 동일 결과가 갱신됩니다. 네트워크를 끊은 뒤 재시도하면 에러 메시지가 표시됩니다.

리액트 커스텀 훅 만들기 "useFetch — 안전한 데이터 패칭(요청 취소/무한 루프 방지)" 예시 화면

빠른 체크리스트

  • 의존성 배열 정확히: useEffect/useCallback deps 누락 금지
  • 정리(cleanup): 패칭 취소·리스너/타이머 해제는 기본
  • SSR 고려: window/localStorage 접근은 서버에서 가드
  • 일관된 API: 훅 반환값 형태(객체/배열) 통일

참고 링크

함께 보면 좋은 게시글

위로 스크롤