서론
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으로 만들어봐야겠음!
'프론트엔드 > React' 카테고리의 다른 글
| (React) React Query vs SWR vs 직접 fetch (1) | 2025.12.08 |
|---|---|
| (React) Virtual DOM과 Reconciliation: React의 렌더링 최적화 원리 (0) | 2025.12.08 |
| (React) Framer Motion 애니메이션 만들어보기 (0) | 2025.12.01 |
| (React) useMemo vs useCallback 차이 (0) | 2025.11.20 |
| (React) props drilling 문제와 해결법 (0) | 2025.11.18 |