커스텀 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의 핵심 개념을 학습했습니다:
- 기본 개념: "use"로 시작하는 재사용 가능한 함수
- 상태 관리 Hook: useCounter, useInput, useForm
- 데이터 페칭: useFetch로 API 호출 로직 재사용
- 로컬스토리지: useLocalStorage로 데이터 영속성
- 타이머 관리: useTimer로 시간 관련 기능
- 토글 상태: useToggle로 불린 상태 관리
- 복합 Hook: 여러 Hook을 조합한 복잡한 로직
- 작성 규칙: Hook의 규칙을 지켜 안전한 Hook 작성
커스텀 Hook은 React의 강력한 기능 중 하나로, 로직을 재사용하고 컴포넌트를 깔끔하게 유지할 수 있게 해줍니다. 적절한 커스텀 Hook 사용으로 더 유지보수하기 쉬운 React 애플리케이션을 만들 수 있습니다.
다음 장에서는 React Router를 활용한 라우팅에 대해 알아보겠습니다.