프론트엔드/React

(React) Custom Hooks 만들기

그린티_ 2025. 12. 2. 13:20
반응형

서론

React를 쓰다 보니 useState, useEffect 같은 Hook들을 자주 쓰게 됨
근데 같은 로직을 여러 컴포넌트에서 반복하게 되는걸 확인함
"이걸 어디 따로 빼서 재사용할 수 없을까?" 싶었음
그러다가 Custom Hooks라는 게 있다는 걸 알게 됨
Custom Hooks를 쓰면 로직을 재사용 가능한 함수로 만들 수 있다고 함
그래서 Custom Hooks가 뭐고, 어떻게 만드는지 정리해봄

본론

Custom Hooks란?

Custom Hook은 state 로직을 재사용하기 위한 JavaScript 함수
여러 컴포넌트에서 같은 로직이 반복되면, 그걸 Custom Hook으로 만들어서 공유할 수 있음

Custom Hook의 규칙:

  • 함수 이름은 use로 시작해야 함 (useXxx 형태)
  • Hook은 Hook 안에서만 호출 가능 (조건부로 호출 불가)
  • 컴포넌트 최상단에서만 호출

기본 구조:

// ✅ 올바른 Custom Hook
function useCounter() {
  const [count, setCount] = useState(0);

  const increment = () => setCount(count + 1);

  return { count, increment };
}

// ✅ 올바른 사용
function MyComponent() {
  const { count, increment } = useCounter(); // 최상단에서 호출
  return <button onClick={increment}>{count}</button>;
}

// ❌ 잘못된 사용
function BadComponent() {
  if (true) {
    const { count } = useCounter(); // 조건부 호출 - 금지!
  }
}

예제 1: useCounter - 가장 간단한 Custom Hook

import { useState } from 'react';

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  const reset = () => setCount(initialValue);

  return { count, increment, decrement, reset };
}

// 사용 예시
function Counter() {
  const { count, increment, decrement, reset } = useCounter(0);

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={increment}>증가</button>
      <button onClick={decrement}>감소</button>
      <button onClick={reset}>초기화</button>
    </div>
  );
}

function AnotherCounter() {
  const { count, increment } = useCounter(10); // 다른 초기값으로 사용

  return (
    <div>
      <p>다른 카운트: {count}</p>
      <button onClick={increment}>증가</button>
    </div>
  );
}

benefit:

  • 로직이 한 곳에만 있음
  • 여러 컴포넌트에서 재사용
  • 유지보수가 쉬움

예제 2: useInput - 폼 입력 관리

폼에서 입력값을 관리하는 로직은 자주 반복됨

import { useState } from 'react';

function useInput(initialValue = '') {
  const [value, setValue] = useState(initialValue);

  const handleChange = (e) => {
    setValue(e.target.value);
  };

  const reset = () => {
    setValue(initialValue);
  };

  return {
    value,
    handleChange,
    reset,
    bind: { value, onChange: handleChange } // spread 용도
  };
}

// 사용 예시
function LoginForm() {
  const email = useInput('');
  const password = useInput('');

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log(`${email.value}로 로그인`);
    email.reset();
    password.reset();
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        placeholder="이메일"
        {...email.bind}
      />
      <input
        type="password"
        placeholder="비밀번호"
        {...password.bind}
      />
      <button type="submit">로그인</button>
    </form>
  );
}

이렇게 쓰면:

  • 매 입력마다 useState 따로 안 해도 됨
  • handleChange 따로 정의 안 해도 됨
  • 폼 초기화도 자동

예제 3: useFetch - API 호출 관리

데이터를 받아오는 로직도 자주 반복됨

import { useState, useEffect } from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isMounted = true; // 언마운트 시 상태 업데이트 방지

    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url);

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        const json = await response.json();

        if (isMounted) {
          setData(json);
          setError(null);
        }
      } catch (err) {
        if (isMounted) {
          setError(err.message);
          setData(null);
        }
      } finally {
        if (isMounted) {
          setLoading(false);
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false; // 클린업 함수
    };
  }, [url]);

  return { data, loading, error };
}

// 사용 예시
function PostList() {
  const { data: posts, loading, error } = useFetch(
    'https://jsonplaceholder.typicode.com/posts'
  );

  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>에러: {error}</div>;

  return (
    <ul>
      {posts?.map(post => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </li>
      ))}
    </ul>
  );
}

function UserDetail({ userId }) {
  const { data: user, loading } = useFetch(
    `https://jsonplaceholder.typicode.com/users/${userId}`
  );

  if (loading) return <div>로딩 중...</div>;

  return (
    <div>
      <h2>{user?.name}</h2>
      <p>Email: {user?.email}</p>
    </div>
  );
}

좋은 점:

  • 모든 API 호출에 일관된 로딩/에러 처리
  • isMounted 체크로 메모리 누수 방지
  • 여러 컴포넌트에서 재사용

예제 4: useLocalStorage - 로컬 스토리지 동기화

로컬 스토리지에 자동으로 저장하는 Hook

import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  // 초기값 설정
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  // 값이 바뀔 때마다 로컬 스토리지에 저장
  const setValue = (value) => {
    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];
}

// 사용 예시
function ThemeSwitcher() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };

  return (
    <div style={{
      background: theme === 'light' ? '#fff' : '#333',
      color: theme === 'light' ? '#000' : '#fff',
      padding: '20px'
    }}>
      <p>현재 테마: {theme}</p>
      <button onClick={toggleTheme}>테마 변경</button>
    </div>
  );
}

function TodoList() {
  const [todos, setTodos] = useLocalStorage('todos', []);

  const addTodo = (text) => {
    setTodos([...todos, { id: Date.now(), text }]);
  };

  return (
    <div>
      <input
        onKeyPress={(e) => {
          if (e.key === 'Enter') {
            addTodo(e.target.value);
            e.target.value = '';
          }
        }}
        placeholder="할 일 입력"
      />
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
}

특징:

  • useState처럼 사용 가능 ([value, setValue])
  • 페이지 새로고침해도 데이터 유지
  • JSON 직렬화/역직렬화 자동 처리

예제 5: useDebounce - 입력값 지연 처리

검색 같은 경우, 사용자가 입력할 때마다 API 호출하면 비효율적
입력이 멈춘 후에 한 번만 호출하는 게 좋음

import { useState, useEffect } from 'react';

function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    // delay 후에 값 업데이트
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // 새로운 입력이 들어오면 기존 타이머 취소
    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

// 사용 예시
function SearchUsers() {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState([]);
  const debouncedSearchTerm = useDebounce(searchTerm, 300);

  useEffect(() => {
    if (debouncedSearchTerm.length > 0) {
      // 300ms 후에 한 번만 API 호출
      console.log(`검색: ${debouncedSearchTerm}`);
      // fetch(`/api/search?q=${debouncedSearchTerm}`)
    }
  }, [debouncedSearchTerm]);

  return (
    <div>
      <input
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="사용자 검색"
      />
      <p>검색어가 300ms 멈춘 후에 검색됩니다!</p>
    </div>
  );
}

흐름:

사용자가 'a' 입력 → 300ms 기다림
사용자가 'ab' 입력 → 타이머 초기화 → 300ms 기다림
사용자가 'abc' 입력 → 타이머 초기화 → 300ms 기다림
사용자가 입력 멈춤 → 300ms 경과 → API 호출!

예제 6: useToggle - boolean 상태 관리

on/off를 자주 하는 경우 유용

import { useState } from 'react';

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

  const toggle = () => setValue(!value);
  const setTrue = () => setValue(true);
  const setFalse = () => setValue(false);

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

// 사용 예시
function Modal() {
  const [isOpen, { toggle, setTrue, setFalse }] = useToggle(false);

  return (
    <div>
      <button onClick={toggle}>모달 열기/닫기</button>
      <button onClick={setTrue}>강제로 열기</button>
      <button onClick={setFalse}>강제로 닫기</button>

      {isOpen && (
        <div style={{
          position: 'fixed',
          top: 0,
          left: 0,
          right: 0,
          bottom: 0,
          background: 'rgba(0,0,0,0.5)',
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'center'
        }}>
          <div style={{ background: 'white', padding: '20px' }}>
            <h2>모달</h2>
            <button onClick={toggle}>닫기</button>
          </div>
        </div>
      )}
    </div>
  );
}

Custom Hook vs 일반 함수

Custom Hook:

function useCounter() {
  const [count, setCount] = useState(0); // Hook 사용 가능
  return { count, setCount };
}

일반 함수:

function getCounter() {
  // ❌ 일반 함수에서는 Hook 사용 불가
  const [count, setCount] = useState(0);
}

차이점:

  • Custom Hook: use로 시작, 다른 Hook 호출 가능
  • 일반 함수: Hook 호출 불가, 순수 로직만 가능

Custom Hook 만들 때 팁

1. 단일 책임 원칙

// ❌ 너무 많은 일을 하는 Hook
function useEverything() {
  const [data, setData] = useState(null);
  const [theme, setTheme] = useState('light');
  const [user, setUser] = useState(null);
  // ... 너무 많음
}

// ✅ 하나의 역할만 하는 Hook
function useFetch(url) { /* ... */ }
function useTheme() { /* ... */ }
function useAuth() { /* ... */ }

2. 문서화하기

/**
 * 카운터 상태를 관리하는 Hook
 * @param {number} initialValue - 초기값 (기본값: 0)
 * @returns {Object} { count, increment, decrement, reset }
 */
function useCounter(initialValue = 0) {
  // ...
}

3. 테스트 가능하게

// Hook을 따로 파일로 분리 (테스트 쉬움)
// hooks/useCounter.js
export function useCounter(initialValue = 0) {
  // ...
}

// __tests__/useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import { useCounter } from '../hooks/useCounter';

test('카운트가 증가한다', () => {
  const { result } = renderHook(() => useCounter());
  act(() => {
    result.current.increment();
  });
  expect(result.current.count).toBe(1);
});

결론

Custom Hook은 React의 가장 강력한 기능 중 하나

같은 state 로직을 여러 컴포넌트에서 재사용할 수 있고, 컴포넌트 코드도 간단해짐
use로 시작하는 함수만 만들면 다른 Hook을 자유롭게 조합할 수 있음

처음엔 useCounter, useInput, useFetch 같은 간단한 Hook부터 시작하고
경험이 쌓이면 더 복잡한 로직을 Hook으로 추상화할 수 있음

Custom Hook을 잘 만드는 능력은 React 개발자의 수준을 보여주는 지표!
앞으로 반복되는 로직이 보이면 바로 Custom Hook으로 만들어봐야겠음!

반응형