본문 바로가기

커스텀 Hook

3. 커스텀 Hook

커스텀 Hook은 컴포넌트 로직을 재사용 가능한 함수로 분리하는 React의 강력한 기능입니다. 여러 컴포넌트에서 같은 로직을 사용해야 할 때, 커스텀 Hook으로 만들어서 코드 중복을 줄이고 관리를 쉽게 할 수 있습니다.

커스텀 Hook이란?

커스텀 Hook은 이름이 "use"로 시작하고, 다른 Hook들을 호출할 수 있는 JavaScript 함수입니다.

기본 커스텀 Hook 예제

import { useState } from 'react';

// 커스텀 Hook: useCounter
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 };
}

// 커스텀 Hook 사용하기
function CounterApp() {
  const { count, increment, decrement, reset } = useCounter(10);

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

function AnotherCounter() {
  const { count, increment, reset } = useCounter(0);

  return (
    <div>
      <h3>다른 카운터</h3>
      <p>클릭 횟수: {count}</p>
      <button onClick={increment}>클릭!</button>
      <button onClick={reset}>리셋</button>
    </div>
  );
}

입력 상태 관리 Hook

useInput Hook

import { useState } from 'react';

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

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

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

  const clear = () => {
    setValue('');
  };

  return {
    value,
    onChange: handleChange,
    reset,
    clear
  };
}

// 사용 예제
function LoginForm() {
  const username = useInput('');
  const password = useInput('');

  const handleSubmit = (event) => {
    event.preventDefault();
    
    if (username.value && password.value) {
      alert(`로그인: ${username.value}`);
      username.clear();
      password.clear();
    } else {
      alert('모든 필드를 입력해주세요');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h3>로그인</h3>
      <div>
        <input
          type="text"
          placeholder="사용자명"
          {...username}
        />
      </div>
      <div>
        <input
          type="password"
          placeholder="비밀번호"
          {...password}
        />
      </div>
      <button type="submit">로그인</button>
      <button type="button" onClick={() => {
        username.reset();
        password.reset();
      }}>
        초기화
      </button>
    </form>
  );
}

useForm Hook

import { useState } from 'react';

function useForm(initialValues = {}) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});

  const handleChange = (event) => {
    const { name, value, type, checked } = event.target;
    const fieldValue = type === 'checkbox' ? checked : value;
    
    setValues(prev => ({
      ...prev,
      [name]: fieldValue
    }));
  };

  const validate = (validationRules) => {
    const newErrors = {};
    
    Object.keys(validationRules).forEach(field => {
      const rule = validationRules[field];
      const value = values[field];
      
      if (rule.required && (!value || value.toString().trim() === '')) {
        newErrors[field] = `${field}는 필수입니다`;
      } else if (rule.minLength && value.length < rule.minLength) {
        newErrors[field] = `${field}는 최소 ${rule.minLength}자 이상이어야 합니다`;
      } else if (rule.pattern && !rule.pattern.test(value)) {
        newErrors[field] = `${field} 형식이 올바르지 않습니다`;
      }
    });
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const reset = () => {
    setValues(initialValues);
    setErrors({});
  };

  return {
    values,
    errors,
    handleChange,
    validate,
    reset
  };
}

// 사용 예제
function RegistrationForm() {
  const form = useForm({
    name: '',
    email: '',
    password: ''
  });

  const validationRules = {
    name: { required: true, minLength: 2 },
    email: { 
      required: true, 
      pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ 
    },
    password: { required: true, minLength: 6 }
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    
    if (form.validate(validationRules)) {
      alert('회원가입 성공!');
      form.reset();
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h3>회원가입</h3>
      
      <div>
        <input
          type="text"
          name="name"
          value={form.values.name}
          onChange={form.handleChange}
          placeholder="이름"
        />
        {form.errors.name && <p>{form.errors.name}</p>}
      </div>

      <div>
        <input
          type="email"
          name="email"
          value={form.values.email}
          onChange={form.handleChange}
          placeholder="이메일"
        />
        {form.errors.email && <p>{form.errors.email}</p>}
      </div>

      <div>
        <input
          type="password"
          name="password"
          value={form.values.password}
          onChange={form.handleChange}
          placeholder="비밀번호"
        />
        {form.errors.password && <p>{form.errors.password}</p>}
      </div>

      <button type="submit">가입하기</button>
      <button type="button" onClick={form.reset}>초기화</button>
    </form>
  );
}

데이터 페칭 Hook

useFetch Hook

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);
      setError(null);

      try {
        // 실제 API 호출 대신 시뮬레이션
        await new Promise(resolve => setTimeout(resolve, 1000));
        
        // 가짜 데이터
        const mockData = {
          '/api/users': [
            { id: 1, name: '김개발', email: 'kim@example.com' },
            { id: 2, name: '이디자인', email: 'lee@example.com' }
          ],
          '/api/posts': [
            { id: 1, title: 'React 시작하기', content: 'React는...' },
            { id: 2, title: 'Hook 사용법', content: 'Hook은...' }
          ]
        };

        setData(mockData[url] || null);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return { data, loading, error };
}

// 사용 예제
function UserList() {
  const { data: users, loading, error } = useFetch('/api/users');

  if (loading) return <p>사용자 목록을 불러오는 중...</p>;
  if (error) return <p>오류: {error}</p>;

  return (
    <div>
      <h3>사용자 목록</h3>
      <ul>
        {users?.map(user => (
          <li key={user.id}>
            {user.name} ({user.email})
          </li>
        ))}
      </ul>
    </div>
  );
}

function PostList() {
  const { data: posts, loading, error } = useFetch('/api/posts');

  if (loading) return <p>게시글을 불러오는 중...</p>;
  if (error) return <p>오류: {error}</p>;

  return (
    <div>
      <h3>게시글 목록</h3>
      {posts?.map(post => (
        <div key={post.id}>
          <h4>{post.title}</h4>
          <p>{post.content}</p>
        </div>
      ))}
    </div>
  );
}

로컬 스토리지 Hook

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 Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [language, setLanguage] = useLocalStorage('language', 'ko');
  const [notifications, setNotifications] = useLocalStorage('notifications', true);

  return (
    <div>
      <h3>설정 (자동 저장됨)</h3>
      
      <div>
        <label>테마: </label>
        <select value={theme} onChange={(e) => setTheme(e.target.value)}>
          <option value="light">밝은 테마</option>
          <option value="dark">어두운 테마</option>
        </select>
      </div>

      <div>
        <label>언어: </label>
        <select value={language} onChange={(e) => setLanguage(e.target.value)}>
          <option value="ko">한국어</option>
          <option value="en">English</option>
        </select>
      </div>

      <div>
        <label>
          <input
            type="checkbox"
            checked={notifications}
            onChange={(e) => setNotifications(e.target.checked)}
          />
          알림 받기
        </label>
      </div>

      <div>
        <h4>현재 설정:</h4>
        <p>테마: {theme}</p>
        <p>언어: {language}</p>
        <p>알림: {notifications ? '켜짐' : '꺼짐'}</p>
      </div>
    </div>
  );
}

타이머 Hook

useTimer Hook

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

function useTimer(initialTime = 0) {
  const [time, setTime] = useState(initialTime);
  const [isRunning, setIsRunning] = useState(false);
  const intervalRef = useRef(null);

  useEffect(() => {
    if (isRunning) {
      intervalRef.current = setInterval(() => {
        setTime(prevTime => prevTime + 1);
      }, 1000);
    } else {
      clearInterval(intervalRef.current);
    }

    return () => clearInterval(intervalRef.current);
  }, [isRunning]);

  const start = () => setIsRunning(true);
  const stop = () => setIsRunning(false);
  const reset = () => {
    setTime(initialTime);
    setIsRunning(false);
  };

  const formatTime = (seconds) => {
    const mins = Math.floor(seconds / 60);
    const secs = seconds % 60;
    return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  };

  return {
    time,
    formattedTime: formatTime(time),
    isRunning,
    start,
    stop,
    reset
  };
}

// 사용 예제
function StopWatch() {
  const { formattedTime, isRunning, start, stop, reset } = useTimer(0);

  return (
    <div>
      <h3>스톱워치</h3>
      <p>{formattedTime}</p>
      <button onClick={start} disabled={isRunning}>시작</button>
      <button onClick={stop} disabled={!isRunning}>정지</button>
      <button onClick={reset}>리셋</button>
    </div>
  );
}

function PomodoroTimer() {
  const { time, formattedTime, isRunning, start, stop, reset } = useTimer(25 * 60); // 25분
  
  useEffect(() => {
    if (time <= 0 && isRunning) {
      alert('포모도로 타이머 완료!');
      stop();
    }
  }, [time, isRunning, stop]);

  return (
    <div>
      <h3>포모도로 타이머 (25분)</h3>
      <p>{formattedTime}</p>
      <button onClick={start} disabled={isRunning || time <= 0}>시작</button>
      <button onClick={stop} disabled={!isRunning}>정지</button>
      <button onClick={reset}>리셋</button>
    </div>
  );
}

토글 Hook

useToggle Hook

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 ToggleExamples() {
  const [isVisible, { toggle: toggleVisible }] = useToggle(false);
  const [isEnabled, { toggle: toggleEnabled, setTrue: enable, setFalse: disable }] = useToggle(true);

  return (
    <div>
      <h3>토글 Hook 예제</h3>
      
      <div>
        <button onClick={toggleVisible}>
          {isVisible ? '숨기기' : '보이기'}
        </button>
        {isVisible && <p>토글된 내용입니다!</p>}
      </div>

      <div>
        <button onClick={toggleEnabled}>
          {isEnabled ? '비활성화' : '활성화'}
        </button>
        <button onClick={enable}>활성화</button>
        <button onClick={disable}>비활성화</button>
        <p>상태: {isEnabled ? '활성' : '비활성'}</p>
      </div>
    </div>
  );
}

실습: 종합 예제

할 일 목록 앱 with 커스텀 Hook

import { useState } from 'react';

// 할 일 목록 관리 커스텀 Hook
function useTodos() {
  const [todos, setTodos] = useLocalStorage('todos', []);

  const addTodo = (text) => {
    if (text.trim()) {
      const newTodo = {
        id: Date.now(),
        text: text.trim(),
        completed: false,
        createdAt: new Date().toISOString()
      };
      setTodos(prev => [...prev, newTodo]);
      return true;
    }
    return false;
  };

  const toggleTodo = (id) => {
    setTodos(prev => 
      prev.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const deleteTodo = (id) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  };

  const editTodo = (id, newText) => {
    if (newText.trim()) {
      setTodos(prev => 
        prev.map(todo =>
          todo.id === id ? { ...todo, text: newText.trim() } : todo
        )
      );
      return true;
    }
    return false;
  };

  const clearCompleted = () => {
    setTodos(prev => prev.filter(todo => !todo.completed));
  };

  const getStats = () => ({
    total: todos.length,
    completed: todos.filter(todo => todo.completed).length,
    pending: todos.filter(todo => !todo.completed).length
  });

  return {
    todos,
    addTodo,
    toggleTodo,
    deleteTodo,
    editTodo,
    clearCompleted,
    stats: getStats()
  };
}

// 메인 컴포넌트
function TodoApp() {
  const { todos, addTodo, toggleTodo, deleteTodo, clearCompleted, stats } = useTodos();
  const newTodo = useInput('');
  const [filter, setFilter] = useState('all'); // all, active, completed

  const handleAddTodo = (event) => {
    event.preventDefault();
    if (addTodo(newTodo.value)) {
      newTodo.clear();
    }
  };

  const filteredTodos = todos.filter(todo => {
    if (filter === 'active') return !todo.completed;
    if (filter === 'completed') return todo.completed;
    return true;
  });

  return (
    <div>
      <h2>할 일 목록 앱</h2>
      
      <form onSubmit={handleAddTodo}>
        <input
          {...newTodo}
          placeholder="새 할 일을 입력하세요"
        />
        <button type="submit">추가</button>
      </form>

      <div>
        <button 
          onClick={() => setFilter('all')}
          disabled={filter === 'all'}
        >
          전체 ({stats.total})
        </button>
        <button 
          onClick={() => setFilter('active')}
          disabled={filter === 'active'}
        >
          진행중 ({stats.pending})
        </button>
        <button 
          onClick={() => setFilter('completed')}
          disabled={filter === 'completed'}
        >
          완료 ({stats.completed})
        </button>
      </div>

      <ul>
        {filteredTodos.map(todo => (
          <li key={todo.id}>
            <label>
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id)}
              />
              <span>
                {todo.text}
              </span>
            </label>
            <button onClick={() => deleteTodo(todo.id)}>삭제</button>
          </li>
        ))}
      </ul>

      {stats.completed > 0 && (
        <button onClick={clearCompleted}>
          완료된 항목 삭제
        </button>
      )}
    </div>
  );
}

커스텀 Hook 작성 규칙

Best Practices

// ✅ 좋은 커스텀 Hook
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;
}

// ❌ 피해야 할 패턴
function badHook() { // "use"로 시작하지 않음
  const [value, setValue] = useState(0);
  return [value, setValue];
}

function useConditionalHook(condition) {
  if (condition) { // 조건부로 Hook 호출 (금지!)
    const [value, setValue] = useState(0);
    return [value, setValue];
  }
  return [null, () => {}];
}

정리

커스텀 Hook의 핵심 개념을 학습했습니다:

  1. 기본 개념: "use"로 시작하는 재사용 가능한 함수
  2. 상태 관리 Hook: useCounter, useInput, useForm
  3. 데이터 페칭: useFetch로 API 호출 로직 재사용
  4. 로컬스토리지: useLocalStorage로 데이터 영속성
  5. 타이머 관리: useTimer로 시간 관련 기능
  6. 토글 상태: useToggle로 불린 상태 관리
  7. 복합 Hook: 여러 Hook을 조합한 복잡한 로직
  8. 작성 규칙: Hook의 규칙을 지켜 안전한 Hook 작성

커스텀 Hook은 React의 강력한 기능 중 하나로, 로직을 재사용하고 컴포넌트를 깔끔하게 유지할 수 있게 해줍니다. 적절한 커스텀 Hook 사용으로 더 유지보수하기 쉬운 React 애플리케이션을 만들 수 있습니다.

다음 장에서는 React Router를 활용한 라우팅에 대해 알아보겠습니다.

커스텀 Hook - 리엑트 베이스캠프 | 위니버시티