[ReactNative] React 훅 8개를 인과 흐름으로 이해하기
useState부터 Custom Hook까지, React의 주요 훅 8개를 알파벳 순이 아니라 '이전 훅의 한계가 다음 훅을 낳았다'는 인과 흐름으로 정리합니다.
이 글은 React/React Native를 막 시작했고, useState·useEffect 정도 이름만 들어본 입문자를 대상으로 합니다. 8개 훅을 알파벳 순이 아니라 “왜 이런 게 필요해졌는가”라는 흐름으로 엮어서 정리했습니다.
🤔 왜 이 글을 쓰게 됐나
React 훅을 처음 공부할 때 가장 힘들었던 건, 8개 훅의 이름과 시그니처를 외워도 머리에 남는 게 없다는 점이었습니다. useMemo와 useCallback은 결국 같은 거 아닌가 싶고, useReducer는 굳이 왜 있나 싶고, useEffectEvent 같은 이름은 들으면 더 헷갈립니다.
훅을 알파벳 순으로 외우면 안 됩니다. 각 훅이 이전 훅의 한계나 문제의식에서 태어났기 때문입니다. 그 인과를 따라가면 8개가 7개의 단계로 자연스럽게 정렬됩니다.
이 글은 다음 흐름으로 진행합니다.
| # | 단계 | 핵심 질문 | 다루는 훅 |
|---|---|---|---|
| 0 | 훅이란 무엇인가 | “그래서 훅은 일반 함수랑 뭐가 다른가?” | (정의) |
| 1 | 컴포넌트는 함수다 | “함수가 호출될 때마다 실행되는데 값을 어떻게 기억하지?” | useState |
| 2 | 렌더링과 부수 효과 | “화면 그리기 외의 일은 언제 해야 하지?” | useEffect |
| 3 | 렌더링과 무관한 값 | “리렌더 일으키지 않고 값을 기억하고 싶어” | useRef |
| 4 | 상태가 복잡해지면 | “여러 상태가 얽혀서 useState로 감당이 안 돼” | useReducer |
| 5 | 불필요한 재계산 막기 | “렌더할 때마다 무거운 계산이 또 돌아…” | useMemo, useCallback |
| 6 | Effect의 함정 | “최신 값을 쓰고 싶지만 의존성에 넣으면 또 돈다” | useEffectEvent |
| 7 | 재사용을 위한 포장 | “이 훅 조합을 다른 컴포넌트에서도 쓰고 싶어” | Custom Hook |
🎯 훅이란 정확히 무엇인가
본문에 들어가기 전에, “훅”이라는 단어부터 정확히 합의하고 갑니다. 훅은 공통 로직을 담은 일반 함수가 아닙니다.
훅(Hook) = React의 내부 상태 시스템에 “연결(hook into)”되는 함수.
이름 그대로입니다. 갈고리(hook)로 React 내부에 걸어서 상태와 라이프사이클을 빌려 쓰는 함수입니다. 일반 함수와의 차이는 다음 표에 압축됩니다.
| 일반 함수 | 훅 | |
|---|---|---|
| 호출 시 React에 뭔가 등록? | ❌ 안 함 | ✅ “이 컴포넌트의 N번째 슬롯”에 상태/effect 등록 |
| 호출 위치 제약 | 없음 | 컴포넌트나 다른 훅의 최상위에서만 |
| 같은 입력 → 같은 결과? | 입력 같으면 동일 (순수) | React 내부 상태에 따라 다른 값 반환 |
| 이름 규약 | 자유 | use로 시작해야 함 |
코드로 비교하면 차이가 분명해집니다.
1
2
3
4
5
6
7
8
9
10
11
// 일반 함수 — 어디서 불러도 같은 입력엔 같은 결과
function calculateTotal(items, coupon) {
return items.reduce((sum, x) => sum + x.price, 0) * (coupon ? 0.9 : 1);
}
// 훅 — React 내부 시스템과 연결됨
function useCart() {
const [items, setItems] = useState([]); // ← React에 "내 상태 슬롯 줘"
useEffect(() => { /* ... */ }, []); // ← React에 "마운트 후 이거 실행해줘"
return { items, setItems };
}
슬롯이라는 표현은 비유가 아니다
훅이 “React의 슬롯에 연결된다”는 말은 비유가 아니라 실제 구현입니다. 화면에 그려진 모든 컴포넌트 인스턴스마다 React는 Fiber 노드라는 내부 객체를 하나씩 만들어 두고, 그 안의 memoizedState 필드에 훅 슬롯들을 링크드 리스트로 저장합니다.
1
2
3
4
5
6
7
8
[Counter 인스턴스]
│
▼
[Fiber 노드]
memoizedState ──→ [슬롯0: count=5] ──→ [슬롯1: name="Lee"] ──→ [슬롯2: effect]
▲ ▲ ▲
useState(0) useState("") useEffect(...)
(1번째 호출) (2번째 호출) (3번째 호출)
핵심은 호출 순서가 슬롯의 인덱스 역할을 한다는 점입니다. React는 변수명이나 매개변수를 보지 않습니다. 오직 “이 렌더에서 몇 번째로 호출된 훅인가”만 봅니다.
1
2
3
4
5
6
7
function MyComponent() {
const [a] = useState(0); // 슬롯 0
if (someCondition) {
const [b] = useState(""); // ❌ 조건 따라 호출되면 슬롯이 어긋남
}
const [c] = useState(false); // 슬롯 1? 2?
}
조건문 안에서 훅을 호출하면 렌더마다 호출 순서가 달라져 슬롯과 변수가 어긋납니다. 그래서 훅은 항상 같은 순서로, 컴포넌트나 다른 훅의 최상위에서만 호출해야 합니다. 이 규칙은 컨벤션이 아니라 물리적으로 어길 수 없는 제약입니다.
이제 본격적으로 8개 훅을 봅니다.
1. useState — 값을 기억하고, 바뀌면 알린다
React의 컴포넌트는 결국 함수입니다.
1
2
3
4
5
6
7
8
function Counter() {
let count = 0;
return (
<Pressable onPress={() => count++}>
<Text>{count}</Text>
</Pressable>
);
}
이 코드는 동작하지 않습니다. 두 가지 이유 때문입니다.
- 함수가 호출되고 끝나면 지역 변수
count는 사라집니다. 다시 호출되면 다시0입니다. - 설령 값이 어딘가에 살아남는다 해도,
count++한다고 React가 “다시 그려야겠다”는 걸 알 방법이 없습니다.
함수 컴포넌트는 두 가지 능력이 필요합니다.
- 호출이 끝나도 값을 유지하는 능력 (기억)
- 값이 바뀌면 컴포넌트를 다시 호출시키는 능력 (재렌더링 트리거)
이걸 한 묶음으로 해주는 게 useState입니다.
1
2
3
4
5
6
7
8
9
function Counter() {
const [count, setCount] = useState(0);
return (
<Pressable onPress={() => setCount(count + 1)}>
<Text>{count}</Text>
</Pressable>
);
}
setCount를 호출하면 React는 두 가지를 합니다.
- 새 값을 슬롯에 저장
Counter()를 다시 호출해서 화면 다시 그리기
“그럼 그냥 함수 바깥에
let count = 0두면 되잖아?”라고 생각할 수 있습니다. 그러면 ①(기억)은 풀리지만 ②(알림)는 여전히 풀리지 않습니다.useState의 본질은 “기억 + 알림”이 한 묶음으로 나온다는 점입니다. 이게 곧 나올useRef와의 결정적 차이입니다.
2. useEffect — 렌더 끝난 뒤 외부 세계와 동기화
컴포넌트 함수의 본분은 “이 상태에서 화면이 어떻게 생겨야 하는가” 라는 JSX를 반환하는 것 단 하나입니다. 그런데 실제 앱에는 화면 그리기 외의 일이 잔뜩 있습니다.
- 화면이 나타나면 API 호출해서 데이터 가져오기
- WebSocket 같은 것에 구독했다가 사라질 때 해제
- 타이머 시작·정지
이런 걸 컴포넌트 함수 본문에 그냥 쓰면 무한 루프가 됩니다.
1
2
3
4
5
6
7
8
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// ❌ fetch → setUser → 리렌더 → 또 fetch → 무한 반복
fetch(`/api/users/${userId}`).then(r => r.json()).then(setUser);
return <Text>{user?.name}</Text>;
}
무한 루프뿐만 아니라, 렌더링은 순수해야 한다는 React의 대원칙도 깨집니다. React는 동시성 모드나 StrictMode에서 한 번의 렌더 함수를 여러 번 호출하기도 하기 때문에, 부수 효과가 본문에 있으면 예측 불가능해집니다.
useEffect는 React에게 이렇게 말하는 도구입니다.
“이 일은 렌더링이 끝나고 화면이 그려진 다음에 해줘.”
1
2
3
4
5
6
7
8
9
10
11
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, [userId]);
return <Text>{user?.name}</Text>;
}
세 부분으로 나뉩니다.
| 부분 | 역할 |
|---|---|
() => { ... } | Effect 함수. 렌더 끝난 뒤 실행될 작업 |
[userId] | 의존성 배열. 이 값들이 이전 렌더와 달라진 경우에만 다시 실행 |
return () => {...} | 클린업 함수. 다음 Effect 실행 전 또는 언마운트 시 정리 |
의존성 배열 3가지 모드
1
2
3
useEffect(() => { ... }); // 매 렌더마다 실행 (거의 안 씀)
useEffect(() => { ... }, []); // 마운트 시 1회만
useEffect(() => { ... }, [a, b]); // a나 b가 이전과 달라진 렌더에서만
핵심 직관: 의존성 배열은 “Effect 안에서 사용한 외부 값”을 적는 자리입니다. 이걸 어겨서 생기는 문제가 6단계의 주제입니다.
클린업은 가장 자주 까먹는 부분
구독·타이머·이벤트 리스너는 시작했으면 반드시 정리해야 합니다.
1
2
3
4
useEffect(() => {
const id = setInterval(() => console.log("tick"), 1000);
return () => clearInterval(id);
}, []);
클린업은 두 시점에 실행됩니다.
- 다음 Effect가 실행되기 직전 (의존성이 바뀌어 다시 실행될 때)
- 컴포넌트가 사라질 때 (언마운트)
useEffect를 라이프사이클 훅으로만 보면 안 됩니다. 본질은 “의존성이 바뀔 때마다 외부 세계를 현재 상태에 맞춰 다시 맞춰주는 동기화” 입니다. 이 관점이 6단계useEffectEvent이해의 핵심이 됩니다.
3. useRef — 알림 없이 값만 기억
1단계에서 useState의 정체를 두 가지로 나눴습니다.
| 역할 | 설명 |
|---|---|
| ① 기억 | 함수가 다시 호출돼도 값 유지 |
| ② 알림 | 값이 바뀌면 다시 렌더 |
가끔 ①은 필요한데 ②가 있으면 곤란한 상황이 있습니다.
1
2
3
4
5
6
7
8
9
function StopWatch() {
const [time, setTime] = useState(0);
let intervalId; // ❌ 매 렌더마다 새로 생김
const start = () => {
intervalId = setInterval(() => setTime(t => t + 1), 1000);
};
const stop = () => clearInterval(intervalId); // ❌ 이때의 intervalId는 다른 값
}
intervalId는 화면에 보이지도 않고, 바뀐다고 다시 그릴 필요도 없습니다. 그냥 여러 렌더에 걸쳐 살아남는 변수가 필요할 뿐입니다. 이때 useState를 쓰면 불필요한 리렌더가 일어납니다.
useRef는 알림 기능을 뗀 useState 입니다.
1
2
3
4
5
6
7
8
9
function StopWatch() {
const [time, setTime] = useState(0);
const intervalRef = useRef(null);
const start = () => {
intervalRef.current = setInterval(() => setTime(t => t + 1), 1000);
};
const stop = () => clearInterval(intervalRef.current);
}
useRef의 반환값은 { current: initialValue } 모양의 객체입니다. 컴포넌트가 살아있는 동안 React가 이 객체를 보존하고, ref.current를 직접 바꿔도 리렌더는 일어나지 않습니다.
| 항목 | useState | useRef |
|---|---|---|
| 값 변경 방법 | setX(newValue) | ref.current = newValue |
| 변경 시 리렌더 | ✅ 일어남 | ❌ 안 일어남 |
| 용도 | 화면에 보이는 값 | 무대 뒤 보관소, 명령형 접근 |
RN에서 자주 쓰는 또 다른 용도는 컴포넌트 ref를 통한 명령형 접근입니다.
1
2
3
4
5
6
7
8
9
10
function LoginForm() {
const passwordRef = useRef(null);
return (
<>
<TextInput onSubmitEditing={() => passwordRef.current.focus()} />
<TextInput ref={passwordRef} secureTextEntry />
</>
);
}
화면에 보여줘야 하는 값은
useState, 무대 뒤 보관소는useRef. 이 갈림길만 잡고 가면 됩니다.
여기까지가 기본 3종 세트입니다. 다음부터는 “기본기로는 풀리지 않는 상황”입니다.
4. useReducer — 사건이 여러 상태를 묶어서 바꿀 때
useState는 독립적인 단일 값을 다룰 땐 깔끔합니다. 그런데 실제 화면에는 이런 상황이 자주 옵니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function CheckoutScreen() {
const [items, setItems] = useState([]);
const [coupon, setCoupon] = useState(null);
const [totalPrice, setTotalPrice] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const addItem = (item) => {
setItems([...items, item]);
setTotalPrice(/* 재계산 */);
setError(null);
};
const submit = async () => {
setIsSubmitting(true);
setError(null);
try { /* ... */ } catch (e) {
setError(e);
setIsSubmitting(false);
}
};
}
문제 세 가지가 동시에 터집니다.
- 상태들이 서로 얽혀 있다. 한쪽
setX를 까먹으면 상태 불일치 - “무엇이 일어났는지”가 코드에 안 보인다.
addItem을 보려면setX세 줄을 다 읽어야 의도가 보임 - 상태 전이 규칙이 컴포넌트 곳곳에 흩어져 있음
useReducer의 발상은 두 가지입니다.
- 상태들을 객체 하나로 합친다.
- “어떻게 바꿀지”가 아니라 “무슨 일이 일어났는지(action)”를 보낸다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const initialState = {
items: [],
coupon: null,
totalPrice: 0,
isSubmitting: false,
error: null,
};
function reducer(state, action) {
switch (action.type) {
case "ADD_ITEM":
return {
...state,
items: [...state.items, action.payload],
totalPrice: calc(state.items.length + 1, state.coupon),
error: null,
};
case "SUBMIT_START":
return { ...state, isSubmitting: true, error: null };
case "SUBMIT_FAIL":
return { ...state, isSubmitting: false, error: action.payload };
default:
return state;
}
}
function CheckoutScreen() {
const [state, dispatch] = useReducer(reducer, initialState);
const addItem = (item) => dispatch({ type: "ADD_ITEM", payload: item });
}
useState | useReducer | |
|---|---|---|
| 컴포넌트가 보내는 것 | 새 값 (“이걸로 바꿔줘”) | 이벤트 (“이런 일이 일어났어”) |
| 변경 규칙 위치 | 컴포넌트 안 곳곳 | reducer 함수 한 곳에 집중 |
| 적합한 상황 | 독립적인 단순 값 | 여러 값이 얽혀서 함께 움직이는 상태 |
언제 useReducer로 갈아탈까
명확한 경계는 없지만 신호가 있습니다.
- 한 함수에서
setX를 3개 이상 부르고 있다 - 어떤 상태가 다른 상태에서 파생된다 (
totalPrice가items+coupon에서 계산) - “이 행동 = 이 상태 변경들 묶음”이라고 이름 붙일 수 있는 사건이 있다
반대로 다크모드 토글 같은 단일 boolean은 항상 useState가 낫습니다. 괜히 reducer 만들면 보일러플레이트만 늘어납니다.
useReducer의 진짜 가치는 “set 누락 방지”보다 “상태 변경의 이유에 이름이 붙는다” 는 점입니다. 6개월 뒤에 코드를 봐도dispatch({ type: "ADD_ITEM" })한 줄로 의도가 한눈에 들어옵니다. iOS 진영에서 TCA를 써본 적이 있다면 거의 같은 발상입니다.
5. useMemo와 useCallback — 불필요한 재계산 막기
함수 컴포넌트의 결정적 특징을 다시 짚습니다.
상태가 바뀔 때마다 컴포넌트 함수 전체가 다시 호출된다.
이 말은 곧 다음을 의미합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function ProductList({ products }) {
const [search, setSearch] = useState("");
// 🚨 search 한 글자 칠 때마다 다시 정렬
const sortedProducts = products
.slice()
.sort((a, b) => a.price - b.price);
// 🚨 매 렌더마다 새 함수 (새 참조!)
const handlePress = (id) => navigation.push("Detail", { id });
return (
<FlatList
data={sortedProducts}
renderItem={({ item }) => <ProductRow product={item} onPress={handlePress} />}
/>
);
}
search가 바뀌면 ProductList 다시 호출 → sortedProducts 다시 정렬, handlePress 다시 생성. 두 가지 문제가 생깁니다.
- 비싼 계산이 매번 돈다
- 매번 새 참조로 만들어진 객체·함수가 자식한테 props로 내려가면, 자식도 “props 바뀌었네” 판단하고 다시 렌더된다
useMemo는 값을, useCallback은 함수를 캐시한다
1
2
3
4
5
6
7
const sortedProducts = useMemo(() => {
return products.slice().sort((a, b) => a.price - b.price);
}, [products]);
const handlePress = useCallback((id) => {
navigation.push("Detail", { id });
}, [navigation]);
읽는 법: “의존성이 안 바뀌었으면 이전 값(함수)을 그대로 재사용해.”
사실 useCallback(fn, deps) ≡ useMemo(() => fn, deps) 입니다. 함수 캐시 전용 단축형입니다.
모든 값을 다 감싸면 되나? ❌
가장 자주 빠지는 함정입니다. useMemo/useCallback도 공짜가 아닙니다.
- 의존성 배열 비교 비용
- 이전 값 보관 메모리
- 코드 가독성 하락
1
2
const x = useMemo(() => a + b, [a, b]); // 🤦 덧셈을 캐시?
const handleClick = useCallback(() => setOpen(true), []); // 🤦 자식한테도 안 넘기는 함수
쓸 때 기준은 두 가지입니다.
- 계산 자체가 비싸거나 (큰 배열 sort/filter)
- 이 값/함수가 다른 훅의 의존성으로 들어가서 참조 안정성이 필요할 때
특히 2번은 입문자가 놓치기 쉬운 효용입니다.
1
2
3
4
5
const config = { theme: "dark" }; // 매 렌더마다 새 객체
useEffect(() => {
applyConfig(config);
}, [config]); // 🚨 매 렌더마다 effect 재실행 (내용은 같은데 참조가 다르니까)
이걸 useMemo로 감싸면 참조가 안정돼서 effect가 멈춥니다.
메모이제이션이라는 용어
React 문서에서 자주 보이는 “메모이제이션(memoization)”은 컴퓨터 과학 일반 개념입니다. 라틴어 memorandum(“기억해야 할 것”) → “memoize”라는 동사. “같은 입력에는 같은 출력이 나온다는 점을 이용해, 계산 결과를 캐시해두고 재사용하는 기법” 입니다.
React에서 메모이제이션을 적용하는 도구는 셋입니다.
| 도구 | 무엇을 메모이제이션? |
|---|---|
useMemo | 값 (의존성 같으면 계산 스킵) |
useCallback | 함수 (의존성 같으면 새 함수 안 만듦) |
React.memo | 컴포넌트 (props 같으면 리렌더 스킵) |
React.memo도 그냥 다 감싸면 되나? ❌
같은 함정이 컴포넌트 차원에서도 그대로 적용됩니다. React.memo는 매 렌더마다 props를 얕은 비교합니다. 컴포넌트가 가벼우면 비교 비용 > 리렌더 비용 이 됩니다. 게다가 부모에서 props 참조를 안정시키지 않으면 무용지물입니다.
1
2
3
4
5
6
7
8
function Parent() {
return (
<ProductRow
product={{ id: 1, name: "사과" }} // ❌ 매 렌더마다 새 객체
onPress={() => console.log("tap")} // ❌ 매 렌더마다 새 함수
/>
);
}
이러면 ProductRow를 React.memo로 감싸도 효과가 없습니다. React.memo는 부모 쪽의 useMemo/useCallback과 한 세트로 움직여야 의미가 있습니다.
실무에서 React.memo가 가치 있는 자리는 의외로 좁습니다.
FlatList/SectionList의renderItem컴포넌트 (스크롤 성능 차이가 큼)- 렌더링 비용이 크고 props가 명확히 안정적인 컴포넌트
- 성능 문제가 측정됐을 때
React 19와 함께 등장한 React Compiler가 정식화되면
useMemo/useCallback/React.memo를 컴파일러가 자동으로 끼워줍니다. 즉 수동 메모이제이션의 시대는 점점 끝나고 있습니다. 지금은 손으로 잡아야 하지만, 습관적으로 모든 걸 다 감싸는 코드는 미래에는 오히려 노이즈가 될 가능성이 높습니다.
6. useEffectEvent — Effect 안의 “이벤트”를 분리하기
이 훅은 2026년 5월 현재 실험적(experimental) 상태입니다. 정식 React/RN 빌드에서는 직접 사용할 수 없고, 동일한 패턴을
useRef로 흉내내야 합니다. 다만 풀려는 문제가 매우 일반적이라 개념을 이해해 두면 다른 훅의 사고가 깊어집니다.
2단계에서 useEffect의 의존성 배열 규칙을 깔았습니다.
“Effect 안에서 사용한 외부 값은 모두 의존성에 들어가야 한다.”
이 규칙엔 불편한 부작용이 있습니다.
1
2
3
4
5
6
7
8
9
10
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on("connected", () => {
showToast(`연결됨!`, theme); // 🚨 theme도 사용
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
}
theme이 다크/라이트로 바뀌기만 해도 채팅 연결을 끊었다가 다시 맺습니다. 테마 바꾼다고 채팅이 재연결되면 안 됩니다.
그럼 의존성에서 빼면? 더 나쁜 일이 일어납니다. stale closure 문제입니다. theme이 “라이트”일 때 effect가 만들어지면 그 안의 theme은 “라이트”로 박제되어, 사용자가 “다크”로 바꿔도 토스트는 영영 라이트 테마로 뜹니다.
발상의 핵심: Reactive vs Event
여기서 React 팀이 도입한 구분이 결정적입니다. Effect 안의 코드는 두 종류로 나뉩니다.
| 종류 | 예시 | 의존성에 넣어야 하나? |
|---|---|---|
| Reactive | createConnection(roomId) — roomId가 바뀌면 정말로 다시 연결 필요 | ✅ |
| Event | showToast(theme) — “연결되는 사건이 일어났을 때 그 시점의 theme 쓰면 됨” | ❌ |
기존 의존성 배열에는 이 구분을 표현할 자리가 없습니다. 그래서 등장한 게 useEffectEvent입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { experimental_useEffectEvent as useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
// "이건 Event다. 항상 최신 값 보고, 의존성으로 잡지 말자."
const onConnected = useEffectEvent(() => {
showToast(`연결됨!`, theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on("connected", () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ theme 안 넣어도 됨
}
useEffectEvent로 감싼 함수는:
- 항상 최신 props/state를 본다 (stale closure 없음)
- 의존성 배열에 넣을 필요가 없다 (넣어서도 안 됨)
멘탈 모델
1
2
useEffect = "이 외부 시스템과 상태를 동기화해줘"
useEffectEvent = "이건 동기화 대상이 아니라, 사건이 일어났을 때 실행할 행동"
정식화되기 전까지의 우회
3단계에서 살짝 짚었던 패턴이 사실 useEffectEvent의 수동 구현입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
function ChatRoom({ roomId, theme }) {
const themeRef = useRef(theme);
useEffect(() => { themeRef.current = theme; });
useEffect(() => {
const connection = createConnection(roomId);
connection.on("connected", () => {
showToast(`연결됨!`, themeRef.current); // 항상 최신 theme
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}
useEffectEvent는 이 패턴을 React가 공식 API로 깔끔하게 감싼 것이라고 보면 됩니다.
7. Custom Hook — 훅 조합을 함수로 묶기
지금까지 훅 7개를 봤습니다. 실전에서는 이 훅들을 정해진 패턴으로 조합한 코드가 자꾸 반복됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function ProductScreen() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch("/api/products")
.then(r => r.json())
.then(d => { setData(d); setLoading(false); })
.catch(e => { setError(e); setLoading(false); });
}, []);
/* ... */
}
function UserScreen() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
// ...같은 패턴 또 반복...
}
이걸 그냥 일반 함수로 빼면 안 됩니다. 일반 함수 안에선 훅을 호출할 수 없기 때문입니다(0단계의 슬롯 규칙).
해결책은 단순합니다. use로 시작하는 이름의 함수를 만들면, 그 안에서 다른 훅을 호출해도 React가 컴포넌트 훅처럼 추적해 줍니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(url)
.then(r => r.json())
.then(d => { setData(d); setLoading(false); })
.catch(e => { setError(e); setLoading(false); });
}, [url]);
return { data, loading, error };
}
function ProductScreen() {
const { data, loading, error } = useFetch("/api/products");
/* ... */
}
function UserScreen() {
const { data, loading, error } = useFetch("/api/users");
/* ... */
}
Custom Hook의 본질은 “재사용 가능한 로직”
특별한 React API가 아닙니다. 다음 두 가지 약속을 지킨 일반 함수일 뿐입니다.
- 이름이
use로 시작한다 (React/ESLint가 훅으로 인식) - 내부에서 다른 훅을 호출한다
가장 자주 하는 오해: 상태가 공유되지 않는다
1
2
3
4
5
6
function Counter1() {
const { count, inc } = useCounter(); // 인스턴스 1
}
function Counter2() {
const { count, inc } = useCounter(); // 인스턴스 2 (별개)
}
useCounter를 두 컴포넌트에서 호출하면 각자 독립된 상태를 갖습니다. Custom hook이 공유하는 건 로직(코드) 이지 상태(값) 가 아닙니다. 상태 공유가 필요하면 그건 Context API나 상태 관리 라이브러리(Zustand, Redux 등)의 영역입니다.
이건 0단계에서 설명한 “Fiber별로 슬롯이 따로 있다”는 사실의 자연스러운 귀결입니다.
RN에서 자주 만드는 Custom Hook
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 키보드 표시 여부
function useKeyboardVisible() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const showSub = Keyboard.addListener("keyboardDidShow", () => setVisible(true));
const hideSub = Keyboard.addListener("keyboardDidHide", () => setVisible(false));
return () => {
showSub.remove();
hideSub.remove();
};
}, []);
return visible;
}
// 디바운스된 값
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function SearchScreen() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) fetchSearch(debouncedQuery);
}, [debouncedQuery]);
}
한눈에 보는 8개 훅
| 훅 | 한 줄 정리 | 언제 쓰나 |
|---|---|---|
useState | 값 기억 + 바뀌면 리렌더 알림 | 화면에 보이는 단순 상태 |
useEffect | 렌더 끝난 뒤 외부 세계와 동기화 | 부수 효과 (구독, 타이머, API) |
useRef | 알림 없는 기억, ref 객체 보관 | 무대 뒤 보관소, 명령형 접근 |
useReducer | “사건 → 새 상태” 변환을 한 곳에 | 얽힌 여러 상태가 함께 움직일 때 |
useMemo | 값을 메모이제이션 | 비싼 계산, 참조 안정화 |
useCallback | 함수를 메모이제이션 | 자식·effect로 함수 넘길 때 |
useEffectEvent (실험적) | “이건 동기화가 아닌 이벤트” | Effect 안에서 최신값만 필요할 때 |
| Custom Hook | 훅 조합을 함수로 포장 | 같은 훅 패턴이 여러 곳에서 반복될 때 |
인과 흐름 한눈에
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[기본기]
useState (값 + 알림)
↓ "외부 세계와의 일은 어디서?"
useEffect (동기화)
↓ "리렌더 일으키지 않고 기억하고 싶다"
useRef (알림 없는 기억)
[기본기로 안 풀리는 문제]
↓ "여러 상태가 얽혀서 useState로 감당이 안 됨"
useReducer (사건 → 상태)
↓ "리렌더마다 비싼 계산/새 함수가 돈다"
useMemo / useCallback (메모이제이션)
↓ "최신 값을 쓰고 싶지만 의존성에 넣으면 또 돈다"
useEffectEvent (이벤트 분리)
↓ "이 훅 조합 다른 곳에서도 쓰고 싶다"
Custom Hook (포장)
한 문장으로 압축하면:
React 훅은 함수 컴포넌트가 React 내부 상태에 연결되도록 해주는 도구 묶음이고, 8개의 훅은 각각 “값 기억”, “외부 동기화”, “재계산 회피” 같은 서로 다른 문제를 풀기 위해 단계적으로 추가된 도구들이다.
🔗 레퍼런스
해당 자료를 찾아보며 도움이 됐던 링크