포스트

React Custom Hooks 패턴 완벽 가이드: 재사용 가능한 로직 설계

useToggle, useDebounce, useAsync 등 실무에서 자주 사용하는 React 커스텀 훅 15가지 패턴을 TypeScript 코드 예제와 함께 설계 원칙, 테스트 방법까지 상세히 다룹니다.

React Custom Hooks 패턴 완벽 가이드: 재사용 가능한 로직 설계

개요

React를 사용하다 보면 여러 컴포넌트에서 동일한 로직이 반복되는 경우를 자주 만나게 됩니다. 토글 상태 관리, API 호출, 로컬 스토리지 연동 등 비슷한 코드를 매번 작성하는 것은 비효율적입니다. 이러한 문제를 해결하는 것이 바로 Custom Hook(커스텀 훅) 입니다.

이 글에서는 실무에서 자주 사용되는 커스텀 훅 패턴들을 소개하고, 직접 구현하면서 설계 원칙과 베스트 프랙티스를 배워보겠습니다. 훅의 성능 최적화에 대해서는 React Hooks 성능 최적화 가이드를 참고하세요.

학습 목표

  • Custom Hook의 개념과 필요성 이해
  • 다양한 커스텀 훅 패턴 학습 및 구현
  • TypeScript를 활용한 타입 안전한 훅 설계
  • 테스트 작성법과 베스트 프랙티스 습득

사전 지식


Custom Hook이란?

Custom Hook은 React의 내장 훅(useState, useEffect 등)을 활용하여 재사용 가능한 로직을 캡슐화한 함수입니다. 컴포넌트에서 상태 관련 로직을 추출하여 독립적으로 테스트하고 재사용할 수 있게 해줍니다.

왜 Custom Hook이 필요한가?

다음과 같은 토글 로직이 여러 컴포넌트에서 사용된다고 가정해봅시다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function ModalComponent() {
  const [isOpen, setIsOpen] = useState(false);

  const toggle = () => setIsOpen(prev => !prev);
  const open = () => setIsOpen(true);
  const close = () => setIsOpen(false);

  return (
    <div>
      <button onClick={toggle}>Toggle Modal</button>
      {isOpen && <Modal onClose={close} />}
    </div>
  );
}

이 로직이 10개의 컴포넌트에서 필요하다면? 매번 같은 코드를 복사하는 것은 다음과 같은 문제를 야기합니다:

  1. 코드 중복: 동일한 로직이 여러 곳에 산재
  2. 유지보수 어려움: 로직 변경 시 모든 곳을 수정해야 함
  3. 테스트 복잡성: 각 컴포넌트마다 동일한 테스트 필요
  4. 일관성 부재: 구현 방식이 조금씩 달라질 수 있음

Custom Hook으로 추출하면 이 모든 문제가 해결됩니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// useToggle 커스텀 훅
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 };
}

// 사용하는 컴포넌트
function ModalComponent() {
  const modal = useToggle(false);

  return (
    <div>
      <button onClick={modal.toggle}>Toggle Modal</button>
      {modal.value && <Modal onClose={modal.setFalse} />}
    </div>
  );
}

Custom Hook 작성 규칙

1. use 접두사 필수

모든 커스텀 훅은 반드시 use로 시작해야 합니다. 이는 단순한 컨벤션이 아니라 React가 훅 규칙 위반을 감지하기 위해 필요합니다.

1
2
3
4
5
6
7
8
9
// 올바른 예
function useCounter() { /* ... */ }
function useLocalStorage() { /* ... */ }
function useWindowSize() { /* ... */ }

// 잘못된 예 - React가 훅으로 인식하지 못함
function counter() { /* ... */ }
function getLocalStorage() { /* ... */ }
function windowSizeHook() { /* ... */ }

2. 훅 규칙 준수

커스텀 훅 내부에서도 React의 훅 규칙을 반드시 준수해야 합니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 잘못된 예 - 조건문 안에서 훅 호출
function useBadExample(condition: boolean) {
  if (condition) {
    const [state, setState] = useState(0); // 위반!
  }
  // ...
}

// 올바른 예
function useGoodExample(condition: boolean) {
  const [state, setState] = useState(0);

  useEffect(() => {
    if (condition) {
      // 조건부 로직은 여기서 처리
    }
  }, [condition]);

  return state;
}

3. 반환값 설계

커스텀 훅의 반환값은 사용 패턴에 따라 설계합니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 배열 반환 - useState처럼 구조 분해 시 이름 자유롭게 지정
function useCounter(initial: number) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount(c => c + 1);
  return [count, increment] as const;
}

const [clicks, addClick] = useCounter(0);
const [views, addView] = useCounter(0);

// 객체 반환 - 여러 값을 반환할 때 명확함
function useAsync<T>() {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  return { data, loading, error };
}

const { data, loading, error } = useAsync<User[]>();

상태 관리 훅 패턴

useToggle - Boolean 토글

가장 기본적이면서도 자주 사용되는 훅입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { useState, useCallback } from 'react';

interface UseToggleReturn {
  value: boolean;
  toggle: () => void;
  setTrue: () => void;
  setFalse: () => void;
  setValue: (value: boolean) => void;
}

function useToggle(initialValue = false): UseToggleReturn {
  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, setValue };
}

export default useToggle;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
function DarkModeToggle() {
  const darkMode = useToggle(false);

  return (
    <div>
      <button onClick={darkMode.toggle}>
        {darkMode.value ? '라이트 모드로 전환' : '다크 모드로 전환'}
      </button>
      <p>현재 모드: {darkMode.value ? '다크' : '라이트'}</p>
    </div>
  );
}

useCounter - 숫자 증감

숫자 상태를 다루는 다양한 기능을 제공합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { useState, useCallback } from 'react';

interface UseCounterOptions {
  min?: number;
  max?: number;
  step?: number;
}

interface UseCounterReturn {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
  set: (value: number) => void;
}

function useCounter(
  initialValue = 0,
  options: UseCounterOptions = {}
): UseCounterReturn {
  const { min = -Infinity, max = Infinity, step = 1 } = options;
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => {
    setCount(prev => Math.min(prev + step, max));
  }, [step, max]);

  const decrement = useCallback(() => {
    setCount(prev => Math.max(prev - step, min));
  }, [step, min]);

  const reset = useCallback(() => {
    setCount(initialValue);
  }, [initialValue]);

  const set = useCallback((value: number) => {
    setCount(Math.max(min, Math.min(value, max)));
  }, [min, max]);

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

export default useCounter;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function QuantitySelector() {
  const quantity = useCounter(1, { min: 1, max: 99, step: 1 });

  return (
    <div>
      <button onClick={quantity.decrement} disabled={quantity.count <= 1}>
        -
      </button>
      <span>{quantity.count}</span>
      <button onClick={quantity.increment} disabled={quantity.count >= 99}>
        +
      </button>
      <button onClick={quantity.reset}>초기화</button>
    </div>
  );
}

useInput - 입력 필드 관리

폼 입력 필드의 상태와 이벤트 핸들러를 관리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import { useState, useCallback, ChangeEvent } from 'react';

interface UseInputOptions<T> {
  validate?: (value: T) => string | null;
  transform?: (value: string) => T;
}

interface UseInputReturn<T> {
  value: T;
  onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
  reset: () => void;
  setValue: (value: T) => void;
  error: string | null;
  isDirty: boolean;
  bind: {
    value: T;
    onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
  };
}

function useInput<T extends string | number = string>(
  initialValue: T,
  options: UseInputOptions<T> = {}
): UseInputReturn<T> {
  const { validate, transform } = options;
  const [value, setValue] = useState<T>(initialValue);
  const [error, setError] = useState<string | null>(null);
  const [isDirty, setIsDirty] = useState(false);

  const onChange = useCallback((
    e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const rawValue = e.target.value;
    const newValue = transform
      ? transform(rawValue)
      : (rawValue as T);

    setValue(newValue);
    setIsDirty(true);

    if (validate) {
      setError(validate(newValue));
    }
  }, [validate, transform]);

  const reset = useCallback(() => {
    setValue(initialValue);
    setError(null);
    setIsDirty(false);
  }, [initialValue]);

  return {
    value,
    onChange,
    reset,
    setValue,
    error,
    isDirty,
    bind: { value, onChange },
  };
}

export default useInput;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
function LoginForm() {
  const email = useInput('', {
    validate: (value) => {
      if (!value.includes('@')) return '유효한 이메일을 입력하세요';
      return null;
    },
  });

  const password = useInput('', {
    validate: (value) => {
      if (value.length < 8) return '비밀번호는 8자 이상이어야 합니다';
      return null;
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!email.error && !password.error) {
      console.log('로그인:', { email: email.value, password: password.value });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input type="email" placeholder="이메일" {...email.bind} />
        {email.isDirty && email.error && (
          <span style={{ color: 'red' }}>{email.error}</span>
        )}
      </div>
      <div>
        <input type="password" placeholder="비밀번호" {...password.bind} />
        {password.isDirty && password.error && (
          <span style={{ color: 'red' }}>{password.error}</span>
        )}
      </div>
      <button type="submit">로그인</button>
    </form>
  );
}

useArray - 배열 조작

배열 상태를 다루는 다양한 유틸리티를 제공합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import { useState, useCallback } from 'react';

interface UseArrayReturn<T> {
  array: T[];
  set: (newArray: T[]) => void;
  push: (element: T) => void;
  filter: (callback: (element: T, index: number) => boolean) => void;
  update: (index: number, newElement: T) => void;
  remove: (index: number) => void;
  clear: () => void;
  isEmpty: boolean;
}

function useArray<T>(initialArray: T[] = []): UseArrayReturn<T> {
  const [array, setArray] = useState<T[]>(initialArray);

  const push = useCallback((element: T) => {
    setArray(prev => [...prev, element]);
  }, []);

  const filter = useCallback((
    callback: (element: T, index: number) => boolean
  ) => {
    setArray(prev => prev.filter(callback));
  }, []);

  const update = useCallback((index: number, newElement: T) => {
    setArray(prev => {
      const copy = [...prev];
      copy[index] = newElement;
      return copy;
    });
  }, []);

  const remove = useCallback((index: number) => {
    setArray(prev => prev.filter((_, i) => i !== index));
  }, []);

  const clear = useCallback(() => {
    setArray([]);
  }, []);

  return {
    array,
    set: setArray,
    push,
    filter,
    update,
    remove,
    clear,
    isEmpty: array.length === 0,
  };
}

export default useArray;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

function TodoList() {
  const todos = useArray<Todo>([
    { id: 1, text: 'React 공부하기', completed: false },
    { id: 2, text: '커스텀 훅 만들기', completed: false },
  ]);
  const newTodo = useInput('');

  const addTodo = () => {
    if (newTodo.value.trim()) {
      todos.push({
        id: Date.now(),
        text: newTodo.value,
        completed: false,
      });
      newTodo.reset();
    }
  };

  const toggleTodo = (index: number) => {
    const todo = todos.array[index];
    todos.update(index, { ...todo, completed: !todo.completed });
  };

  return (
    <div>
      <input {...newTodo.bind} placeholder="새 할일 입력" />
      <button onClick={addTodo}>추가</button>
      <ul>
        {todos.array.map((todo, index) => (
          <li key={todo.id}>
            <span
              onClick={() => toggleTodo(index)}
              style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
            >
              {todo.text}
            </span>
            <button onClick={() => todos.remove(index)}>삭제</button>
          </li>
        ))}
      </ul>
      {!todos.isEmpty && (
        <button onClick={todos.clear}>모두 삭제</button>
      )}
    </div>
  );
}

사이드 이펙트 훅 패턴

useLocalStorage - 로컬 스토리지 연동

로컬 스토리지와 React 상태를 동기화합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import { useState, useEffect, useCallback } from 'react';

function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
  // 초기값 가져오기
  const readValue = useCallback((): T => {
    if (typeof window === 'undefined') {
      return initialValue;
    }

    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  }, [initialValue, key]);

  const [storedValue, setStoredValue] = useState<T>(readValue);

  // 값 설정
  const setValue = useCallback((value: T | ((prev: T) => T)) => {
    try {
      const valueToStore = value instanceof Function
        ? value(storedValue)
        : value;

      setStoredValue(valueToStore);

      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));

        // 다른 탭/창과 동기화를 위한 커스텀 이벤트
        window.dispatchEvent(new StorageEvent('storage', {
          key,
          newValue: JSON.stringify(valueToStore),
        }));
      }
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, storedValue]);

  // 값 제거
  const removeValue = useCallback(() => {
    try {
      if (typeof window !== 'undefined') {
        window.localStorage.removeItem(key);
        setStoredValue(initialValue);
      }
    } catch (error) {
      console.warn(`Error removing localStorage key "${key}":`, error);
    }
  }, [key, initialValue]);

  // 다른 탭에서 변경 시 동기화
  useEffect(() => {
    const handleStorageChange = (event: StorageEvent) => {
      if (event.key === key && event.newValue !== null) {
        setStoredValue(JSON.parse(event.newValue));
      }
    };

    window.addEventListener('storage', handleStorageChange);
    return () => window.removeEventListener('storage', handleStorageChange);
  }, [key]);

  return [storedValue, setValue, removeValue];
}

export default useLocalStorage;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
interface UserPreferences {
  theme: 'light' | 'dark';
  fontSize: number;
  language: string;
}

function SettingsPanel() {
  const [preferences, setPreferences, resetPreferences] =
    useLocalStorage<UserPreferences>('user-preferences', {
      theme: 'light',
      fontSize: 16,
      language: 'ko',
    });

  return (
    <div>
      <h3>설정</h3>
      <div>
        <label>테마:</label>
        <select
          value={preferences.theme}
          onChange={(e) => setPreferences(prev => ({
            ...prev,
            theme: e.target.value as 'light' | 'dark',
          }))}
        >
          <option value="light">라이트</option>
          <option value="dark">다크</option>
        </select>
      </div>
      <div>
        <label>글자 크기: {preferences.fontSize}px</label>
        <input
          type="range"
          min="12"
          max="24"
          value={preferences.fontSize}
          onChange={(e) => setPreferences(prev => ({
            ...prev,
            fontSize: Number(e.target.value),
          }))}
        />
      </div>
      <button onClick={resetPreferences}>설정 초기화</button>
    </div>
  );
}

useDebounce - 디바운스

값의 변경을 지연시켜 불필요한 연산을 줄입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useState, useEffect } from 'react';

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

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

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

  return debouncedValue;
}

export default useDebounce;

함수 버전 (콜백 디바운스):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useCallback, useRef } from 'react';

function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(
  callback: T,
  delay: number
): (...args: Parameters<T>) => void {
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);

  return useCallback((...args: Parameters<T>) => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    timeoutRef.current = setTimeout(() => {
      callback(...args);
    }, delay);
  }, [callback, delay]);
}

export default useDebouncedCallback;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearch = useDebounce(searchTerm, 500);
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    if (debouncedSearch) {
      // API 호출은 디바운스된 값이 변경될 때만 실행
      fetch(`/api/search?q=${debouncedSearch}`)
        .then(res => res.json())
        .then(data => setResults(data));
    }
  }, [debouncedSearch]);

  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="검색어 입력..."
      />
      <p>검색 중: {debouncedSearch}</p>
      <ul>
        {results.map((result, i) => (
          <li key={i}>{result}</li>
        ))}
      </ul>
    </div>
  );
}

useThrottle - 쓰로틀

일정 시간 간격으로만 값을 갱신합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { useState, useEffect, useRef } from 'react';

function useThrottle<T>(value: T, interval: number): T {
  const [throttledValue, setThrottledValue] = useState<T>(value);
  const lastUpdated = useRef<number>(Date.now());

  useEffect(() => {
    const now = Date.now();
    const timeSinceLastUpdate = now - lastUpdated.current;

    if (timeSinceLastUpdate >= interval) {
      lastUpdated.current = now;
      setThrottledValue(value);
    } else {
      const timer = setTimeout(() => {
        lastUpdated.current = Date.now();
        setThrottledValue(value);
      }, interval - timeSinceLastUpdate);

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

  return throttledValue;
}

export default useThrottle;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function MouseTracker() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const throttledPosition = useThrottle(position, 100);

  useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };

    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);

  return (
    <div>
      <p>실제 위치: ({position.x}, {position.y})</p>
      <p>쓰로틀 위치: ({throttledPosition.x}, {throttledPosition.y})</p>
    </div>
  );
}

useInterval - 인터벌

선언적으로 setInterval을 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useEffect, useRef } from 'react';

function useInterval(callback: () => void, delay: number | null): void {
  const savedCallback = useRef<() => void>(callback);

  // 콜백이 변경되면 ref 업데이트
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // 인터벌 설정
  useEffect(() => {
    if (delay === null) {
      return;
    }

    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

export default useInterval;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Timer() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  useInterval(
    () => setSeconds(s => s + 1),
    isRunning ? 1000 : null // null이면 인터벌 중지
  );

  return (
    <div>
      <p>경과 시간: {seconds}</p>
      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? '정지' : '시작'}
      </button>
      <button onClick={() => setSeconds(0)}>리셋</button>
    </div>
  );
}

DOM/이벤트 훅 패턴

useClickOutside - 외부 클릭 감지

요소 외부 클릭을 감지하여 드롭다운, 모달 닫기 등에 활용합니다. 모달 구현에 대한 더 자세한 내용은 React Portal 완벽 가이드를 참고하세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { useEffect, useRef, RefObject } from 'react';

function useClickOutside<T extends HTMLElement>(
  handler: () => void,
  mouseEvent: 'mousedown' | 'mouseup' = 'mousedown'
): RefObject<T> {
  const ref = useRef<T>(null);

  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (ref.current && !ref.current.contains(event.target as Node)) {
        handler();
      }
    };

    document.addEventListener(mouseEvent, handleClickOutside);
    return () => document.removeEventListener(mouseEvent, handleClickOutside);
  }, [handler, mouseEvent]);

  return ref;
}

export default useClickOutside;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useClickOutside<HTMLDivElement>(() => setIsOpen(false));

  return (
    <div ref={dropdownRef}>
      <button onClick={() => setIsOpen(!isOpen)}>
        메뉴 열기
      </button>
      {isOpen && (
        <ul>
          <li>옵션 1</li>
          <li>옵션 2</li>
          <li>옵션 3</li>
        </ul>
      )}
    </div>
  );
}

useKeyPress - 키보드 이벤트

특정 키 입력을 감지합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { useState, useEffect, useCallback } from 'react';

type KeyPressHandler = (event: KeyboardEvent) => void;

interface UseKeyPressOptions {
  targetKey: string;
  onKeyDown?: KeyPressHandler;
  onKeyUp?: KeyPressHandler;
  preventDefault?: boolean;
}

function useKeyPress(options: UseKeyPressOptions): boolean {
  const { targetKey, onKeyDown, onKeyUp, preventDefault = false } = options;
  const [keyPressed, setKeyPressed] = useState(false);

  const downHandler = useCallback((event: KeyboardEvent) => {
    if (event.key === targetKey) {
      if (preventDefault) event.preventDefault();
      setKeyPressed(true);
      onKeyDown?.(event);
    }
  }, [targetKey, onKeyDown, preventDefault]);

  const upHandler = useCallback((event: KeyboardEvent) => {
    if (event.key === targetKey) {
      if (preventDefault) event.preventDefault();
      setKeyPressed(false);
      onKeyUp?.(event);
    }
  }, [targetKey, onKeyUp, preventDefault]);

  useEffect(() => {
    window.addEventListener('keydown', downHandler);
    window.addEventListener('keyup', upHandler);

    return () => {
      window.removeEventListener('keydown', downHandler);
      window.removeEventListener('keyup', upHandler);
    };
  }, [downHandler, upHandler]);

  return keyPressed;
}

export default useKeyPress;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function Game() {
  const [position, setPosition] = useState({ x: 50, y: 50 });

  useKeyPress({
    targetKey: 'ArrowUp',
    onKeyDown: () => setPosition(p => ({ ...p, y: p.y - 10 })),
  });

  useKeyPress({
    targetKey: 'ArrowDown',
    onKeyDown: () => setPosition(p => ({ ...p, y: p.y + 10 })),
  });

  useKeyPress({
    targetKey: 'ArrowLeft',
    onKeyDown: () => setPosition(p => ({ ...p, x: p.x - 10 })),
  });

  useKeyPress({
    targetKey: 'ArrowRight',
    onKeyDown: () => setPosition(p => ({ ...p, x: p.x + 10 })),
  });

  const escPressed = useKeyPress({ targetKey: 'Escape' });

  return (
    <div>
      {escPressed && <p>ESC 키가 눌렸습니다!</p>}
      <div
        style={{
          position: 'absolute',
          left: position.x,
          top: position.y,
          width: 50,
          height: 50,
          backgroundColor: 'blue',
        }}
      />
    </div>
  );
}

useWindowSize - 윈도우 크기

브라우저 윈도우 크기 변화를 감지합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { useState, useEffect } from 'react';

interface WindowSize {
  width: number;
  height: number;
}

function useWindowSize(): WindowSize {
  const [windowSize, setWindowSize] = useState<WindowSize>(() => ({
    width: typeof window !== 'undefined' ? window.innerWidth : 0,
    height: typeof window !== 'undefined' ? window.innerHeight : 0,
  }));

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

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

  return windowSize;
}

export default useWindowSize;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function ResponsiveComponent() {
  const { width, height } = useWindowSize();

  const getDeviceType = () => {
    if (width < 768) return 'mobile';
    if (width < 1024) return 'tablet';
    return 'desktop';
  };

  return (
    <div>
      <p>화면 크기: {width} x {height}</p>
      <p>디바이스 타입: {getDeviceType()}</p>
      {width < 768 ? (
        <MobileNavigation />
      ) : (
        <DesktopNavigation />
      )}
    </div>
  );
}

useScrollPosition - 스크롤 위치

스크롤 위치를 추적합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { useState, useEffect } from 'react';

interface ScrollPosition {
  x: number;
  y: number;
  direction: 'up' | 'down' | null;
  isAtTop: boolean;
  isAtBottom: boolean;
}

function useScrollPosition(): ScrollPosition {
  const [scrollPosition, setScrollPosition] = useState<ScrollPosition>({
    x: 0,
    y: 0,
    direction: null,
    isAtTop: true,
    isAtBottom: false,
  });

  useEffect(() => {
    let previousY = window.scrollY;

    const handleScroll = () => {
      const currentY = window.scrollY;
      const currentX = window.scrollX;
      const direction = currentY > previousY ? 'down' : 'up';

      const isAtTop = currentY === 0;
      const isAtBottom =
        window.innerHeight + currentY >= document.documentElement.scrollHeight;

      setScrollPosition({
        x: currentX,
        y: currentY,
        direction: currentY === previousY ? null : direction,
        isAtTop,
        isAtBottom,
      });

      previousY = currentY;
    };

    window.addEventListener('scroll', handleScroll, { passive: true });
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return scrollPosition;
}

export default useScrollPosition;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function Header() {
  const { y, direction, isAtTop } = useScrollPosition();
  const [isVisible, setIsVisible] = useState(true);

  useEffect(() => {
    if (direction === 'down' && y > 100) {
      setIsVisible(false);
    } else if (direction === 'up') {
      setIsVisible(true);
    }
  }, [direction, y]);

  return (
    <header
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        transform: isVisible ? 'translateY(0)' : 'translateY(-100%)',
        transition: 'transform 0.3s ease',
        backgroundColor: isAtTop ? 'transparent' : 'white',
        boxShadow: isAtTop ? 'none' : '0 2px 4px rgba(0,0,0,0.1)',
      }}
    >
      <nav>내비게이션</nav>
    </header>
  );
}

비동기 훅 패턴

useFetch - 데이터 패칭

API 호출을 위한 선언적 훅입니다. 더 복잡한 서버 상태 관리가 필요하다면 React Query 완벽 가이드를 참고하세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import { useState, useEffect, useCallback } from 'react';

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

interface UseFetchReturn<T> extends UseFetchState<T> {
  refetch: () => void;
}

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

  const fetchData = useCallback(async () => {
    setState(prev => ({ ...prev, loading: true, error: null }));

    try {
      const response = await fetch(url, options);

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

      const data = await response.json();
      setState({ data, loading: false, error: null });
    } catch (error) {
      setState({
        data: null,
        loading: false,
        error: error instanceof Error ? error : new Error('Unknown error')
      });
    }
  }, [url, options]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  return { ...state, refetch: fetchData };
}

export default useFetch;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
interface User {
  id: number;
  name: string;
  email: string;
}

function UserList() {
  const { data: users, loading, error, refetch } = useFetch<User[]>(
    'https://api.example.com/users'
  );

  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>에러: {error.message}</div>;
  if (!users) return <div>데이터가 없습니다</div>;

  return (
    <div>
      <button onClick={refetch}>새로고침</button>
      <ul>
        {users.map(user => (
          <li key={user.id}>
            {user.name} ({user.email})
          </li>
        ))}
      </ul>
    </div>
  );
}

useAsync - 비동기 상태 관리

더 유연한 비동기 작업 관리를 위한 훅입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import { useState, useCallback } from 'react';

type AsyncStatus = 'idle' | 'pending' | 'success' | 'error';

interface UseAsyncState<T> {
  status: AsyncStatus;
  data: T | null;
  error: Error | null;
}

interface UseAsyncReturn<T, P extends unknown[]> extends UseAsyncState<T> {
  execute: (...params: P) => Promise<T | null>;
  reset: () => void;
  isIdle: boolean;
  isPending: boolean;
  isSuccess: boolean;
  isError: boolean;
}

function useAsync<T, P extends unknown[] = []>(
  asyncFunction: (...params: P) => Promise<T>
): UseAsyncReturn<T, P> {
  const [state, setState] = useState<UseAsyncState<T>>({
    status: 'idle',
    data: null,
    error: null,
  });

  const execute = useCallback(async (...params: P): Promise<T | null> => {
    setState({ status: 'pending', data: null, error: null });

    try {
      const data = await asyncFunction(...params);
      setState({ status: 'success', data, error: null });
      return data;
    } catch (error) {
      const err = error instanceof Error ? error : new Error('Unknown error');
      setState({ status: 'error', data: null, error: err });
      return null;
    }
  }, [asyncFunction]);

  const reset = useCallback(() => {
    setState({ status: 'idle', data: null, error: null });
  }, []);

  return {
    ...state,
    execute,
    reset,
    isIdle: state.status === 'idle',
    isPending: state.status === 'pending',
    isSuccess: state.status === 'success',
    isError: state.status === 'error',
  };
}

export default useAsync;

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
interface LoginResponse {
  token: string;
  user: { id: number; name: string };
}

async function loginAPI(email: string, password: string): Promise<LoginResponse> {
  const response = await fetch('/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });

  if (!response.ok) {
    throw new Error('로그인에 실패했습니다');
  }

  return response.json();
}

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const loginAsync = useAsync(loginAPI);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const result = await loginAsync.execute(email, password);

    if (result) {
      console.log('로그인 성공:', result.user.name);
      // 리다이렉트 등 처리
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="이메일"
        disabled={loginAsync.isPending}
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="비밀번호"
        disabled={loginAsync.isPending}
      />
      <button type="submit" disabled={loginAsync.isPending}>
        {loginAsync.isPending ? '로그인 중...' : '로그인'}
      </button>
      {loginAsync.isError && (
        <p style={{ color: 'red' }}>{loginAsync.error?.message}</p>
      )}
      {loginAsync.isSuccess && (
        <p style={{ color: 'green' }}>환영합니다, {loginAsync.data?.user.name}님!</p>
      )}
    </form>
  );
}

고급 패턴

제네릭 활용

TypeScript 제네릭을 활용하면 타입 안전하고 재사용 가능한 훅을 만들 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import { useState, useCallback } from 'react';

// 제네릭 스택 훅
interface UseStackReturn<T> {
  items: T[];
  push: (item: T) => void;
  pop: () => T | undefined;
  peek: () => T | undefined;
  isEmpty: boolean;
  size: number;
  clear: () => void;
}

function useStack<T>(initialItems: T[] = []): UseStackReturn<T> {
  const [items, setItems] = useState<T[]>(initialItems);

  const push = useCallback((item: T) => {
    setItems(prev => [...prev, item]);
  }, []);

  const pop = useCallback((): T | undefined => {
    let poppedItem: T | undefined;
    setItems(prev => {
      if (prev.length === 0) return prev;
      poppedItem = prev[prev.length - 1];
      return prev.slice(0, -1);
    });
    return poppedItem;
  }, []);

  const peek = useCallback((): T | undefined => {
    return items[items.length - 1];
  }, [items]);

  const clear = useCallback(() => {
    setItems([]);
  }, []);

  return {
    items,
    push,
    pop,
    peek,
    isEmpty: items.length === 0,
    size: items.length,
    clear,
  };
}

// 제네릭 맵 훅
function useMap<K, V>(initialMap?: Map<K, V>) {
  const [map, setMap] = useState(new Map<K, V>(initialMap));

  const set = useCallback((key: K, value: V) => {
    setMap(prev => new Map(prev).set(key, value));
  }, []);

  const remove = useCallback((key: K) => {
    setMap(prev => {
      const next = new Map(prev);
      next.delete(key);
      return next;
    });
  }, []);

  const get = useCallback((key: K) => map.get(key), [map]);

  const has = useCallback((key: K) => map.has(key), [map]);

  const clear = useCallback(() => setMap(new Map()), []);

  return { map, set, remove, get, has, clear, size: map.size };
}

훅 조합 (Composition)

여러 훅을 조합하여 더 복잡한 기능을 구현합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// 기본 훅들을 조합한 useForm 훅
interface FormField<T> {
  value: T;
  error: string | null;
  touched: boolean;
}

interface UseFormOptions<T extends Record<string, unknown>> {
  initialValues: T;
  validate?: (values: T) => Partial<Record<keyof T, string>>;
  onSubmit: (values: T) => void | Promise<void>;
}

function useForm<T extends Record<string, unknown>>(options: UseFormOptions<T>) {
  const { initialValues, validate, onSubmit } = options;

  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
  const submitAsync = useAsync(async () => {
    await onSubmit(values);
  });

  const handleChange = useCallback((
    name: keyof T,
    value: T[keyof T]
  ) => {
    setValues(prev => ({ ...prev, [name]: value }));

    if (validate) {
      const validationErrors = validate({ ...values, [name]: value });
      setErrors(prev => ({ ...prev, [name]: validationErrors[name] }));
    }
  }, [values, validate]);

  const handleBlur = useCallback((name: keyof T) => {
    setTouched(prev => ({ ...prev, [name]: true }));
  }, []);

  const handleSubmit = useCallback(async (e: React.FormEvent) => {
    e.preventDefault();

    // 모든 필드를 touched로 설정
    const allTouched = Object.keys(values).reduce(
      (acc, key) => ({ ...acc, [key]: true }),
      {} as Record<keyof T, boolean>
    );
    setTouched(allTouched);

    // 유효성 검사
    if (validate) {
      const validationErrors = validate(values);
      setErrors(validationErrors);

      if (Object.keys(validationErrors).length > 0) {
        return;
      }
    }

    await submitAsync.execute();
  }, [values, validate, submitAsync]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    submitAsync.reset();
  }, [initialValues, submitAsync]);

  const getFieldProps = useCallback((name: keyof T) => ({
    value: values[name],
    onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
      handleChange(name, e.target.value as T[keyof T]),
    onBlur: () => handleBlur(name),
  }), [values, handleChange, handleBlur]);

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    handleSubmit,
    reset,
    getFieldProps,
    isSubmitting: submitAsync.isPending,
    isValid: Object.keys(errors).length === 0,
  };
}

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
interface SignupForm {
  username: string;
  email: string;
  password: string;
}

function SignupPage() {
  const form = useForm<SignupForm>({
    initialValues: {
      username: '',
      email: '',
      password: '',
    },
    validate: (values) => {
      const errors: Partial<Record<keyof SignupForm, string>> = {};

      if (values.username.length < 3) {
        errors.username = '사용자명은 3자 이상이어야 합니다';
      }
      if (!values.email.includes('@')) {
        errors.email = '유효한 이메일을 입력하세요';
      }
      if (values.password.length < 8) {
        errors.password = '비밀번호는 8자 이상이어야 합니다';
      }

      return errors;
    },
    onSubmit: async (values) => {
      await fetch('/api/signup', {
        method: 'POST',
        body: JSON.stringify(values),
      });
    },
  });

  return (
    <form onSubmit={form.handleSubmit}>
      <div>
        <input {...form.getFieldProps('username')} placeholder="사용자명" />
        {form.touched.username && form.errors.username && (
          <span>{form.errors.username}</span>
        )}
      </div>
      <div>
        <input {...form.getFieldProps('email')} placeholder="이메일" />
        {form.touched.email && form.errors.email && (
          <span>{form.errors.email}</span>
        )}
      </div>
      <div>
        <input
          {...form.getFieldProps('password')}
          type="password"
          placeholder="비밀번호"
        />
        {form.touched.password && form.errors.password && (
          <span>{form.errors.password}</span>
        )}
      </div>
      <button type="submit" disabled={form.isSubmitting}>
        {form.isSubmitting ? '가입 중...' : '가입하기'}
      </button>
    </form>
  );
}

조건부 훅 실행 주의사항

React 훅은 항상 동일한 순서로 호출되어야 합니다. 조건부로 훅을 호출하면 오류가 발생합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 잘못된 예 - 조건부 훅 호출
function BadExample({ shouldFetch }: { shouldFetch: boolean }) {
  // 이 코드는 React 훅 규칙을 위반합니다!
  if (shouldFetch) {
    const { data } = useFetch('/api/data'); // 위반!
  }

  return <div>...</div>;
}

// 올바른 예 - 훅은 항상 호출하고, 조건은 내부에서 처리
function GoodExample({ shouldFetch }: { shouldFetch: boolean }) {
  const { data, loading } = useFetch(
    shouldFetch ? '/api/data' : null // null일 때 fetch 스킵하도록 훅 내부 구현
  );

  return <div>...</div>;
}

// 또는 enabled 옵션 패턴 사용
function useFetchWithEnabled<T>(url: string, enabled = true) {
  const [state, setState] = useState<{ data: T | null; loading: boolean }>({
    data: null,
    loading: enabled,
  });

  useEffect(() => {
    if (!enabled) {
      setState({ data: null, loading: false });
      return;
    }

    // fetch 로직...
  }, [url, enabled]);

  return state;
}

테스트 작성법

React Testing Library의 renderHook을 사용하여 커스텀 훅을 테스트합니다. 프론트엔드 테스트에 대한 더 자세한 내용은 프론트엔드 테스트 완벽 가이드를 참고하세요.

테스트 환경 설정

1
npm install --save-dev @testing-library/react @testing-library/jest-dom vitest

useToggle 테스트 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import useToggle from './useToggle';

describe('useToggle', () => {
  it('초기값이 false일 때 value가 false여야 한다', () => {
    const { result } = renderHook(() => useToggle(false));
    expect(result.current.value).toBe(false);
  });

  it('초기값이 true일 때 value가 true여야 한다', () => {
    const { result } = renderHook(() => useToggle(true));
    expect(result.current.value).toBe(true);
  });

  it('toggle 호출 시 값이 반전되어야 한다', () => {
    const { result } = renderHook(() => useToggle(false));

    act(() => {
      result.current.toggle();
    });

    expect(result.current.value).toBe(true);

    act(() => {
      result.current.toggle();
    });

    expect(result.current.value).toBe(false);
  });

  it('setTrue 호출 시 값이 true가 되어야 한다', () => {
    const { result } = renderHook(() => useToggle(false));

    act(() => {
      result.current.setTrue();
    });

    expect(result.current.value).toBe(true);
  });

  it('setFalse 호출 시 값이 false가 되어야 한다', () => {
    const { result } = renderHook(() => useToggle(true));

    act(() => {
      result.current.setFalse();
    });

    expect(result.current.value).toBe(false);
  });
});

useAsync 테스트 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import useAsync from './useAsync';

describe('useAsync', () => {
  it('초기 상태가 idle이어야 한다', () => {
    const asyncFn = vi.fn();
    const { result } = renderHook(() => useAsync(asyncFn));

    expect(result.current.isIdle).toBe(true);
    expect(result.current.status).toBe('idle');
  });

  it('execute 호출 시 pending 상태가 되어야 한다', async () => {
    const asyncFn = vi.fn(() => new Promise(resolve =>
      setTimeout(() => resolve('data'), 100)
    ));
    const { result } = renderHook(() => useAsync(asyncFn));

    act(() => {
      result.current.execute();
    });

    expect(result.current.isPending).toBe(true);
  });

  it('성공 시 success 상태와 데이터가 있어야 한다', async () => {
    const mockData = { id: 1, name: 'Test' };
    const asyncFn = vi.fn(() => Promise.resolve(mockData));
    const { result } = renderHook(() => useAsync(asyncFn));

    await act(async () => {
      await result.current.execute();
    });

    expect(result.current.isSuccess).toBe(true);
    expect(result.current.data).toEqual(mockData);
  });

  it('실패 시 error 상태와 에러가 있어야 한다', async () => {
    const error = new Error('Test error');
    const asyncFn = vi.fn(() => Promise.reject(error));
    const { result } = renderHook(() => useAsync(asyncFn));

    await act(async () => {
      await result.current.execute();
    });

    expect(result.current.isError).toBe(true);
    expect(result.current.error).toEqual(error);
  });

  it('reset 호출 시 idle 상태로 돌아가야 한다', async () => {
    const asyncFn = vi.fn(() => Promise.resolve('data'));
    const { result } = renderHook(() => useAsync(asyncFn));

    await act(async () => {
      await result.current.execute();
    });

    expect(result.current.isSuccess).toBe(true);

    act(() => {
      result.current.reset();
    });

    expect(result.current.isIdle).toBe(true);
    expect(result.current.data).toBeNull();
  });
});

베스트 프랙티스

1. 단일 책임 원칙

각 훅은 하나의 명확한 목적을 가져야 합니다.

1
2
3
4
5
6
7
8
// 좋은 예 - 각 훅이 하나의 책임을 가짐
function useToggle() { /* 토글 로직만 */ }
function useLocalStorage() { /* 로컬 스토리지 로직만 */ }

// 나쁜 예 - 너무 많은 책임
function useEverything() {
  // 토글도 하고, API도 호출하고, 로컬 스토리지도 관리하고...
}

2. 일관된 반환값 구조

훅의 반환값은 일관된 패턴을 따라야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 값 하나만 반환할 때
function useDebounce<T>(value: T, delay: number): T {
  // ...
  return debouncedValue;
}

// 배열 반환 (useState 스타일) - 값과 setter
function useToggle(initial: boolean): [boolean, () => void] {
  // ...
  return [value, toggle];
}

// 객체 반환 - 여러 값이나 메서드가 있을 때
function useAsync<T>() {
  // ...
  return { data, loading, error, execute, reset };
}

3. 의존성 최적화

불필요한 리렌더링을 방지하기 위해 의존성을 최적화합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
function useOptimizedHook() {
  // useCallback으로 함수 메모이제이션
  const handleClick = useCallback(() => {
    // ...
  }, [/* 필요한 의존성만 */]);

  // useMemo로 값 메모이제이션
  const computedValue = useMemo(() => {
    return expensiveCalculation();
  }, [/* 필요한 의존성만 */]);

  return { handleClick, computedValue };
}

4. 에러 처리

에러 상황을 적절히 처리하고 사용자에게 피드백을 제공합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function useRobustAsync<T>(asyncFn: () => Promise<T>) {
  const [state, setState] = useState({
    data: null as T | null,
    error: null as Error | null,
    loading: false,
  });

  const execute = useCallback(async () => {
    setState(prev => ({ ...prev, loading: true, error: null }));

    try {
      const data = await asyncFn();
      setState({ data, error: null, loading: false });
      return data;
    } catch (error) {
      const normalizedError = error instanceof Error
        ? error
        : new Error(String(error));
      setState({ data: null, error: normalizedError, loading: false });

      // 필요시 에러 리포팅
      console.error('Async operation failed:', normalizedError);

      return null;
    }
  }, [asyncFn]);

  return { ...state, execute };
}

5. TypeScript 타입 안전성

제네릭과 타입 가드를 활용하여 타입 안전성을 보장합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 제네릭으로 유연한 타입 지원
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  // ...
}

// 반환 타입을 명시적으로 정의
interface UseCounterReturn {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

function useCounter(initial = 0): UseCounterReturn {
  // ...
}

// const assertion으로 튜플 타입 보장
function useTuple() {
  const state = useState(0);
  return [state[0], state[1]] as const;
}

6. 문서화

JSDoc 주석으로 훅의 사용법을 문서화합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * 불리언 값을 토글하는 커스텀 훅
 *
 * @param initialValue - 초기 불리언 값 (기본값: false)
 * @returns 현재 값과 토글 관련 함수들을 포함한 객체
 *
 * @example
 * ```tsx
 * function Modal() {
 *   const { value: isOpen, toggle, setFalse: close } = useToggle();
 *   return (
 *     <div>
 *       <button onClick={toggle}>Toggle</button>
 *       {isOpen && <ModalContent onClose={close} />}
 *     </div>
 *   );
 * }
 * ```
 */
function useToggle(initialValue = false) {
  // 구현...
}

마무리

이 글에서 다룬 커스텀 훅 패턴들을 정리하면 다음과 같습니다:

카테고리용도
상태 관리useToggle불리언 토글
 useCounter숫자 증감
 useInput입력 필드 관리
 useArray배열 조작
사이드 이펙트useLocalStorage로컬 스토리지 연동
 useDebounce값 디바운스
 useThrottle값 쓰로틀
 useInterval인터벌 관리
DOM/이벤트useClickOutside외부 클릭 감지
 useKeyPress키보드 이벤트
 useWindowSize윈도우 크기
 useScrollPosition스크롤 위치
비동기useFetch데이터 패칭
 useAsync비동기 상태 관리

핵심 포인트

  1. 재사용성: 공통 로직을 훅으로 추출하여 중복을 제거합니다
  2. 테스트 용이성: 훅은 독립적으로 테스트할 수 있습니다
  3. 타입 안전성: TypeScript와 제네릭을 활용하여 타입 오류를 방지합니다
  4. 조합 가능성: 작은 훅들을 조합하여 복잡한 기능을 구현합니다
  5. 규칙 준수: React 훅 규칙을 반드시 지켜야 합니다

커스텀 훅은 React 개발에서 코드 품질과 생산성을 높이는 핵심 도구입니다. 이 글에서 소개한 패턴들을 기반으로 프로젝트에 맞는 커스텀 훅을 설계해보세요. React 19의 새로운 기능과 함께 활용하려면 React 19 완벽 가이드도 참고하시기 바랍니다.

참고 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.