지난 시간에는 prop drilling을 해결하는 도구인 Context를 배우면서, 마지막에 useTheme이라는 함수를 만들어 사용 편의를 높였습니다. 사실 이 useTheme은 **커스텀 훅(Custom Hook)**의 한 예시였습니다. 이번 시간에는 커스텀 훅이 무엇이고 왜 만들고 어떻게 만드는지를 본격적으로 다뤄보겠습니다.
컴포넌트 사이에서 로직을 공유하는 문제
지금까지 우리는 컴포넌트(JSX 반환 함수) 단위로 코드를 재사용했습니다. 그런데 재사용하고 싶은 것이 화면 조각이 아니라 로직이라면 어떨까요?
예를 들어 다음 두 컴포넌트는 거의 같은 로직을 반복합니다.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [userId]);
// ... 화면 렌더링 ...
}function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/posts')
.then(res => res.json())
.then(data => setPosts(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []);
// ... 화면 렌더링 ...
}같은 패턴 — 데이터 가져오기 + 로딩/에러 state가 중복됩니다. 이걸 어떻게 한 곳에 모아 재사용할 수 있을까요? 커스텀 훅이 답입니다.
커스텀 훅이란
커스텀 훅은 이름이 use로 시작하는, 다른 훅을 사용하는 평범한 함수입니다. 정의는 그게 전부예요. 새 문법이 있는 게 아니라, 그저 컨벤션이죠.
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount(prev => prev + 1);
const decrement = () => setCount(prev => prev - 1);
const reset = () => setCount(initial);
return { count, increment, decrement, reset };
}이 함수는 컴포넌트가 아닙니다 (JSX를 반환하지 않으니까요). 하지만 함수 안에서 useState라는 훅을 사용하고 있습니다. 그래서 자기 자신도 훅이 됩니다.
사용하는 쪽:
function Counter() {
const { count, increment, decrement, reset } = useCounter(0);
return (
<div>
<h2>{count}</h2>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>리셋</button>
</div>
);
}useCounter라는 한 줄로 카운터 로직 전체가 캡슐화됐습니다. 같은 훅을 다른 컴포넌트에서도 똑같이 호출하면 그 컴포넌트에도 자기만의 카운터가 생깁니다.
"이름이 use로 시작한다"는 단순한 컨벤션이 아닙니다. 리액트는 함수 이름이 use로 시작하는지로 그 함수가 훅인지 판단하고, 훅의 규칙(아래 설명)을 적용할지를 결정합니다. ESLint의 react-hooks 플러그인도 마찬가지로 이 규칙을 강제합니다. 반드시 use로 시작하게 지으세요.
훅의 규칙
커스텀 훅을 만들든 사용하든, 모든 훅에는 두 가지 규칙이 있습니다.
규칙 1. 훅은 함수의 최상위에서만 호출
function App() {
if (someCondition) {
const [count, setCount] = useState(0); // 🚫
}
}훅은 컴포넌트 함수의 최상위 레벨에서만 호출해야 합니다. 조건문, 반복문, 중첩 함수 안에서 호출하면 안 됩니다. 리액트가 훅의 호출 순서로 어떤 state가 어떤 useState인지 식별하기 때문에, 호출 순서가 매번 같아야 합니다.
규칙 2. 훅은 리액트 함수에서만 호출
훅은 컴포넌트 함수 또는 다른 커스텀 훅 안에서만 호출할 수 있습니다. 일반 자바스크립트 함수에서 호출하면 안 됩니다.
function fetchSomething() {
const [data, setData] = useState(null); // 🚫
}이 규칙들을 어기면 ESLint가 잡아주고, 런타임에 리액트가 에러를 띄웁니다.
자주 만들어 쓰는 커스텀 훅들
직접 만들기도 하고, 라이브러리에서 가져다 쓰기도 하는 흔한 커스텀 훅 예시 몇 개를 살펴보겠습니다.
useToggle — 불리언 토글
import { useState, useCallback } from 'react';
export function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(prev => !prev), []);
return [value, toggle];
}function App() {
const [isOpen, toggleOpen] = useToggle();
return (
<>
<button onClick={toggleOpen}>{isOpen ? '닫기' : '열기'}</button>
{isOpen && <div>패널 내용</div>}
</>
);
}체크박스의 토글, 모달 열고 닫기, 메뉴 펼치고 접기 등 자주 등장하는 패턴이라 한 번 만들어두면 활용도가 높습니다.
useLocalStorage — state ↔ localStorage 동기화
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">라이트</option>
<option value="dark">다크</option>
</select>
);
}useState와 사용법이 거의 같지만, 값이 자동으로 localStorage에 저장되고 페이지 새로고침해도 유지됩니다. 이런 식으로 기본 훅을 그대로 사용하는 듯한 인터페이스를 유지하면 사용하는 쪽이 직관적입니다.
useDebounce — 값 변경을 늦추기
타이핑 중에 매 글자마다 검색을 보내면 서버에 부담입니다. 사용자가 잠시 멈추기를 기다렸다가 보내고 싶을 때 디바운스를 사용합니다.
import { useState, useEffect } from 'react';
export function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (!debouncedQuery) return;
fetch(`/api/search?q=${debouncedQuery}`).then(/* ... */);
}, [debouncedQuery]);
return (
<input value={query} onChange={(e) => setQuery(e.target.value)} />
);
}query는 매 키 입력마다 즉시 바뀌지만, debouncedQuery는 500ms 타이핑이 멈춘 뒤에야 갱신됩니다. 그 결과 검색 요청은 사용자가 잠시 쉴 때만 한 번씩 일어납니다.
cleanup으로 이전 타이머를 취소하는 부분이 핵심입니다 — value가 자주 바뀌면 매번 이전 타이머를 취소하고 새 타이머를 거는 식이라 결과적으로 마지막 변경 후 delay만큼 조용해야 갱신이 일어납니다.
useFetch — 데이터 가져오기
도입부에서 본 중복 패턴을 훅으로 추출해봅시다.
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`요청 실패: ${res.status}`);
return res.json();
})
.then(json => {
if (!cancelled) setData(json);
})
.catch(err => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <p>불러오는 중...</p>;
if (error) return <p>에러: {error}</p>;
return <p>{user.name}</p>;
}
function PostList() {
const { data: posts, loading, error } = useFetch('/api/posts');
if (loading) return <p>불러오는 중...</p>;
if (error) return <p>에러: {error}</p>;
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}도입부의 중복이 사라지고, 각 컴포넌트는 화면 그리는 일에만 집중하게 됐습니다. 같은 로직을 100군데에서 쓴다 해도 훅 한 개를 고치면 100군데가 함께 바뀝니다.
실무에서는 직접 useFetch를 만들기보다 TanStack Query 같은 라이브러리를 쓰는 경우가 많습니다. 캐싱, 재검증, 백그라운드 업데이트, 페이지네이션 등 우리가 직접 구현하기 까다로운 부분을 잘 다듬어 제공하거든요. 다만 그것도 결국 useEffect + useState로 만든 커스텀 훅이라, 원리를 이해해두면 라이브러리 학습이 빨라집니다.
커스텀 훅의 진짜 가치
커스텀 훅을 만들면서 가장 인상적인 점은 추상화의 자유로움입니다. 우리가 추출한 것은 단순한 함수가 아니라 state를 가진 동작 단위입니다. 카운터, 토글, 데이터 페칭, 디바운스 같은 "기능"들을 컴포넌트와 분리해 독립된 단위로 다룰 수 있게 된 것이죠.
또 하나 중요한 점은 각 컴포넌트가 훅을 호출하면 그 인스턴스가 자기만의 state를 갖는다는 사실입니다. useCounter()를 두 컴포넌트가 호출하면 카운트가 두 개 따로 만들어집니다. useState가 그렇듯이요. 즉, 훅은 코드를 공유하지만 state를 공유하지는 않습니다. 둘이 같은 state를 봐야 한다면 #11에서 배운 lifting state up이나 #12의 Context를 써야 합니다.
직접 해보기
지난 글들에서 만든 컴포넌트들을 커스텀 훅으로 정리해봅시다.
src/hooks/useToggle.js:
import { useState, useCallback } from 'react';
export function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(prev => !prev), []);
return [value, toggle];
}src/hooks/useLocalStorage.js:
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}src/App.jsx:
import { useToggle } from './hooks/useToggle';
import { useLocalStorage } from './hooks/useLocalStorage';
function App() {
const [isMenuOpen, toggleMenu] = useToggle();
const [name, setName] = useLocalStorage('userName', '');
return (
<div style={{ padding: '16px' }}>
<h1>커스텀 훅 데모</h1>
<section style={{ marginTop: '16px' }}>
<button onClick={toggleMenu}>{isMenuOpen ? '메뉴 닫기' : '메뉴 열기'}</button>
{isMenuOpen && (
<ul>
<li>홈</li>
<li>소개</li>
<li>연락처</li>
</ul>
)}
</section>
<section style={{ marginTop: '16px' }}>
<p>이름을 입력하세요 (새로고침해도 유지됩니다):</p>
<input value={name} onChange={(e) => setName(e.target.value)} />
{name && <p>안녕하세요, {name}님!</p>}
</section>
</div>
);
}
export default App;토글 메뉴는 클릭할 때마다 열리고 닫히고, 이름 입력은 페이지를 새로고침해도 그대로 유지됩니다. 컴포넌트의 코드는 훨씬 짧아졌고, 토글이나 localStorage 동기화 로직은 다른 곳에서도 그대로 가져다 쓸 수 있습니다.
마무리
이번 글에서는 컴포넌트 사이에서 로직을 공유하는 도구인 커스텀 훅을 배웠습니다. 정리하면:
- 커스텀 훅 = 이름이
use로 시작하고 다른 훅을 사용하는 함수 - 훅의 규칙: 함수 최상위에서만 호출, 컴포넌트나 다른 훅 안에서만 호출
- 자주 만드는 패턴:
useToggle,useLocalStorage,useDebounce,useFetch - 훅은 로직을 공유하지 state를 공유하지는 않는다 (state 공유는 lifting/Context)
- 라이브러리(TanStack Query 등)도 결국 커스텀 훅으로 만들어진 것
지금까지 우리는 "어떻게 동작하게 만드는가"에 집중했습니다. 다음 글인 "리액트 기초 강좌 #14 성능 최적화"에서는 "어떻게 빠르게 돌아가게 만드는가"를 다루는 도구들 — memo, useMemo, useCallback을 살펴봅니다. 흔히 오용되는 도구들이라 언제 써야 하고 언제 안 써야 하는지까지 함께 짚어보겠습니다.