리액트 기초 강좌 #13 커스텀 훅
지난 시간에는 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을 살펴봅니다. 흔히 오용되는 도구들이라 언제 써야 하고 언제 안 써야 하는지까지 함께 짚어보겠습니다.