React 19 공식 문서 기준으로 정리한 내장 Hook 17개 가이드
17개 Hook을 중요도에 따라 세 묶음으로 나눠서 다뤄요. 원하는 Hook으로 바로 이동할 수 있게 한눈에 보기 표를 먼저 둬요.
| 묶음 | Hook |
|---|---|
| 매일 쓰는 Hook | useState, useEffect, useRef, useContext |
| 구조·성능·비동기 UI | useReducer, useMemo, useCallback, useTransition, useDeferredValue, useActionState |
| 알아만 두는 Hook | useLayoutEffect, useInsertionEffect, useImperativeHandle, useEffectEvent, useId, useSyncExternalStore, useDebugValue |
Hook을 하나씩 살펴보기 전에, 이 글 전체에서 계속 나오는 개념 몇 가지를 먼저 잡고 가요. Hook 문서를 읽다가 막히는 부분은 대부분 Hook 자체보다 이 개념들에서 출발해요.
React에서 화면을 이루는 조각을 컴포넌트라고 불러요. 컴포넌트는 "화면에 무엇이 있어야 하는지"를 반환하는 자바스크립트 함수예요.
function Greeting() {
return <h1>안녕하세요</h1>;
}
React에서 화면이 바뀌는 과정은 항상 세 단계로 이뤄져요. 공식 문서는 이것을 식당에 비유해요. 컴포넌트는 주방의 요리사이고, React는 손님의 주문을 주방에 전달하고 완성된 요리를 테이블에 가져다주는 웨이터예요.
| 단계 | 무슨 일이 일어나나요 | 식당에 비유하면 |
|---|---|---|
| 1. 트리거 | 렌더링이 시작돼요 | 손님의 주문을 주방에 전달 |
| 2. 렌더링 | React가 컴포넌트 함수를 호출해서 화면에 무엇을 그릴지 계산해요 | 주방에서 요리 |
| 3. 커밋 | 계산한 결과를 실제 화면(DOM)에 반영해요 | 테이블에 서빙 |
렌더링이 시작되는 이유는 두 가지예요.
state를 바꾸는 set 함수를 호출하면 React가 렌더링을 예약해요.
"렌더링"은 React가 컴포넌트를 호출하는 것이에요. 컴포넌트가 다른 컴포넌트를 반환하면 React는 그 컴포넌트도 이어서 호출하고, 더 이상 호출할 컴포넌트가 없을 때까지 계속해요. 그래서 부모가 렌더링되면 기본적으로 그 안의 자식도 모두 다시 렌더링돼요. 이 사실은 3-2에서 성능을 이야기할 때 중요해져요.
다시 렌더링할 때 React는 이전과 달라진 부분이 있는지 계산해요. 여기까지는 계산일 뿐이고, 아직 화면은 바뀌지 않아요.
계산이 끝나면 React가 실제 화면(DOM)을 바꿔요. 이때 이전 렌더링과 차이가 있는 부분만 바꿔요. 렌더링했다고 해서 화면이 항상 바뀌는 것은 아니에요.
function Clock({ time }) {
return (
<>
<h1>{time}</h1>
<input />
</>
);
}
부모가 매초 새로운 time을 내려줘서 Clock이 매초 다시 렌더링되어도, 입력창에 적어 둔 글자는 사라지지 않아요. 커밋 단계에서 React가 <h1>의 내용만 새 시간으로 바꾸고, <input>은 지난번과 같은 자리에 있으니 건드리지 않기 때문이에요.
페인트는 React의 일이 아니에요. React가 DOM을 바꾸고 나면 브라우저가 화면을 다시 그려요. 이것을 흔히 "브라우저 렌더링"이라고 부르는데, 공식 문서는 React의 렌더링과 헷갈리지 않도록 "페인트"라고 불러요. React에서 말하는 렌더링은 화면을 그리는 것이 아니라 컴포넌트를 호출해서 결과를 계산하는 것이에요.
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Counter가 호출되고 count는 0이에요. 커밋 후 버튼에 0이 보여요.setCount(1)이 호출돼요. "다음에 그릴 때는 count를 1로 해줘"라는 요청이고, 이것이 트리거예요.Counter 함수를 처음부터 다시 호출해요. 이번에는 count가 1이에요.0에서 1로 바뀌어요.여기서 중요한 사실이 하나 있어요. 컴포넌트 함수는 렌더링할 때마다 처음부터 다시 실행돼요. 그래서 함수 안에서 만든 일반 변수는 매번 새로 만들어지고, 값을 계속 기억해 두려면 React가 대신 보관해 주는 장치가 필요해요. 그 장치가 바로 Hook이에요.
이름이 use로 시작하는 함수를 Hook이라고 불러요. 컴포넌트 안에서 React의 기능(값 기억하기, 외부 시스템과 연결하기 등)을 꺼내 쓰게 해줘요. useState처럼 React가 기본으로 제공하는 것도 있고, 이 기본 Hook들을 조합해서 직접 만들 수도 있어요(커스텀 Hook).
Hook에는 지켜야 할 규칙이 두 가지 있어요.
컴포넌트(또는 커스텀 Hook)의 최상위에서, early return보다 앞에서 호출해야 해요. 공식 문서는 아래와 같은 곳에서는 호출할 수 없다고 안내해요.
return 뒤try / catch / finally 블록 안useMemo, useReducer, useEffect에 넘긴 함수 안function Bad({ cond }) {
if (cond) {
// 잘못된 방식: 조건문 안에서 Hook 호출
const theme = useContext(ThemeContext);
}
}
function Bad({ cond }) {
if (cond) {
return;
}
// 잘못된 방식: 조건부 return 뒤에서 Hook 호출
const theme = useContext(ThemeContext);
}
function Bad() {
function handleClick() {
// 잘못된 방식: 이벤트 핸들러 안에서 Hook 호출
const theme = useContext(ThemeContext);
}
}
function Counter() {
// 올바른 방식: 컴포넌트 최상위에서 호출
const [count, setCount] = useState(0);
// ...
}
function useWindowWidth() {
// 올바른 방식: 커스텀 Hook 최상위에서 호출
const [width, setWidth] = useState(window.innerWidth);
// ...
}
고치는 방법은 간단해요. Hook 호출을 조건문이나 함수 바깥, 위쪽으로 옮기면 돼요. 조건에 따라 동작이 달라져야 한다면 조건을 Hook "안"에 넣어요. 조건에 따라 state 자체가 필요하다면, 그 부분을 새 컴포넌트로 분리하고 그 안의 최상위에서 호출하세요.
왜 이런 규칙이 있을까요? 공식 문서는 이렇게 설명해요. 컴포넌트 안에 useState가 여러 개 있을 때, React는 "어떤 state가 어떤 useState 호출의 것인지"를 Hook이 호출된 순서로 알아내요.
function Form() {
// 첫 번째 Hook 호출: name 보관
const [name, setName] = useState('Mary');
// 두 번째 Hook 호출: surname 보관
const [surname, setSurname] = useState('Poppins');
}
렌더링할 때마다 호출 순서가 똑같으면, React는 "첫 번째 칸은 name, 두 번째 칸은 surname"으로 값을 정확히 대응시킬 수 있어요. 그런데 Hook을 조건문 안에 두면 이런 일이 생겨요.
function Form({ showNickname }) {
const [name, setName] = useState('Mary');
if (showNickname) {
// 잘못된 방식: 조건에 따라 호출 순서 변경
const [nickname, setNickname] = useState('');
}
const [surname, setSurname] = useState('Poppins');
}
showNickname이 true일 때는 name → nickname → surname 순서로, false일 때는 name → surname 순서로 호출돼요. 그러면 "두 번째 Hook 호출"이 어떤 때는 nickname이고 어떤 때는 surname이에요. React는 어떤 값을 돌려줘야 할지 알 수 없고, 건너뛴 Hook 뒤의 모든 호출이 한 칸씩 밀려서 버그로 이어져요.
Hook은 컴포넌트나 커스텀 Hook 안에서만 호출할 수 있어요. 일반 자바스크립트 함수 안에서는 호출할 수 없어요. 이 규칙 덕분에 컴포넌트의 state와 관련된 로직이 소스 코드에서 분명하게 보여요. 커스텀 Hook은 다른 Hook을 호출할 수 있고, 그게 커스텀 Hook의 존재 이유예요.
두 규칙은 eslint-plugin-react-hooks가 자동으로 검사해 줘요. 설정에서 react-hooks/rules-of-hooks는 Hook 규칙을, react-hooks/exhaustive-deps는 2-2에서 배울 Effect의 의존성을 검사해요. 린터를 켜 두면 규칙을 어길 때 바로 알려주니 꼭 켜 두세요.
React는 모든 컴포넌트가 순수한 함수라고 가정해요. 순수하다는 것은 두 가지를 뜻해요.
수학 공식과 같아요. y = 2x에서 x가 3이면 y는 언제나 6이에요. 시간대나 주식 시장에 따라 9나 2.5가 되는 일은 없어요. 컴포넌트를 레시피라고 생각해도 돼요. 레시피대로 만들고 조리 중에 새 재료를 끼워 넣지 않으면 매번 같은 요리가 나와요.
let guest = 0;
function Cup() {
// 잘못된 방식: 이미 있던 변수 수정
guest = guest + 1;
return <h2>{guest}번 손님의 찻잔</h2>;
}
export default function TeaSet() {
return (
<>
<Cup />
<Cup />
<Cup />
</>
);
}
Cup은 컴포넌트 밖에 있는 guest 변수를 읽고 써요. 그래서 같은 컴포넌트를 여러 번 호출하면 결과가 달라지고, 다른 컴포넌트가 guest를 읽는다면 렌더링된 순서에 따라 결과가 또 달라져요. 예측할 수 없는 상태예요.
guest를 prop으로 받으면 순수해져요.
function Cup({ guest }) {
return <h2>{guest}번 손님의 찻잔</h2>;
}
export default function TeaSet() {
return (
<>
<Cup guest={1} />
<Cup guest={2} />
<Cup guest={3} />
</>
);
}
이제 Cup이 돌려주는 JSX는 guest prop에만 의존해요.
렌더링 중에 읽을 수 있는 입력은 props, state, context예요. 이 세 가지는 항상 읽기 전용으로 다뤄야 해요. 무언가를 바꾸고 싶다면 변수에 쓰지 말고 state를 바꾸세요.
function TeaGathering() {
// 렌더링 중 새로 만든 배열: 수정해도 안전
const cups = [];
for (let i = 1; i <= 12; i++) {
cups.push(<Cup key={i} guest={i} />);
}
return cups;
}
문제가 되는 것은 렌더링 전에 이미 있던 객체를 바꾸는 경우예요. 같은 렌더링 안에서 새로 만든 배열에 push하는 것은 괜찮아요. 이 컴포넌트 밖에서는 그런 일이 있었는지 아무도 모르기 때문이에요. 공식 문서는 이것을 "지역 변경(local mutation)", 컴포넌트의 작은 비밀이라고 불러요.
화면을 바꾸거나, 애니메이션을 시작하거나, 데이터를 바꾸는 일처럼 "렌더링 계산 이외의 일"을 부수효과라고 해요. 프로그램이 뭔가 의미 있는 일을 하려면 부수효과는 꼭 필요해요. 다만 렌더링 중에 하면 안 돼요.
useEffect를 마지막 수단으로 써요. React가 렌더링이 끝난 뒤에 실행해 줘요. (2-2에서 자세히 다뤄요.)공식 문서는 순수함이 가능하게 해주는 것들을 이렇게 설명해요.
memo).실수를 미리 잡아주는 장치도 있어요. Strict Mode는 개발 환경에서 React가 각 컴포넌트 함수를 두 번 호출해요. 순수하지 않은 컴포넌트는 두 번 호출되면 티가 나기 때문이에요.
위의 순수하지 않은 Cup 예제는 Strict Mode에서 1, 2, 3번 손님이 아니라 2, 4, 6번 손님으로 표시돼요. Cup 하나가 두 번씩 호출되어 guest가 한 번에 2씩 늘었기 때문이에요. 반면 guest를 prop으로 받는 순수한 버전은 두 번 호출돼도 결과가 같아요.
<StrictMode>로 앱을 감싸야 켜져요. 일부 프레임워크는 기본으로 켜 둬요.useState의 초기값 함수와 업데이터 함수, useMemo의 계산 함수, useReducer의 reducer도 개발 중에 두 번 실행돼요. useEffect는 setup → cleanup → setup이 한 번 더 실행돼요.그래서 개발 중에 console.log가 두 번 찍혀도 놀라지 마세요. 버그가 아니에요. 순수한 코드라면 두 번 실행해도 문제가 없어야 해요.
| 말 | 한 줄 설명 | 자세한 설명 |
|---|---|---|
| 트리거 / 렌더링 / 커밋 | 렌더링 시작 / 컴포넌트를 호출해 화면을 계산 / 계산 결과를 실제 화면에 반영 | 위에서 설명했어요 |
| 리렌더링 | state가 바뀌어 컴포넌트를 다시 호출하는 것 | 위에서 설명했어요 |
| 순수한 컴포넌트 | 같은 입력이면 같은 JSX를 돌려주고, 렌더링 중에는 계산만 해요 | 위에서 설명했어요 |
| 스냅샷 | 한 번의 렌더링 안에서 state는 고정된 값이에요. set을 호출해도 그 렌더링 안에서는 값이 그대로예요 | 2-1 useState |
| 불변성 | state에 든 객체나 배열은 직접 고치지 않고, 새로 만들어서 교체해요 | 2-1 useState |
| 의존성 | useEffect가 "이 값이 바뀌면 다시 실행해"라고 지켜보는 값 | 2-2 useEffect |
Hook 중에서도 거의 모든 화면에서 만나게 되는 네 가지를 먼저 살펴볼게요. 예제에 나오는 fetchBio, createConnection, post 같은 함수는 설명을 위해 가정한 예시 함수예요.
| Hook | 한 줄로 말하면 |
|---|---|
useState | 화면에 반영되는 값을 기억해요 |
useEffect | React 밖의 것(타이머, 서버 연결 등)과 컴포넌트를 맞춰줘요 |
useRef | 화면과 상관없는 값이나 DOM 요소를 기억해요 |
useContext | 멀리 있는 부모의 값을 바로 읽어요 |
useState — 컴포넌트가 값을 기억하게 하기컴포넌트가 "기억"해야 하는 값을 만들고, 그 값이 바뀌면 React가 화면을 다시 그리게 해요. 버튼을 누른 횟수, 입력창에 적은 글자, 메뉴가 열려 있는지 여부처럼 사용자의 조작에 따라 바뀌고, 바뀌면 화면에도 반영되어야 하는 값이 state예요.
let count = 0 같은 일반 변수는 두 가지 문제가 있어요.
0으로 돌아가요.state는 React가 대신 보관해 주고, 값이 바뀌면 화면도 다시 그려줘요.
import { useState } from 'react';
function Counter() {
// 현재 값(count)과 값을 바꾸는 함수(setCount)
const [count, setCount] = useState(0);
function handleClick() {
// 버튼 클릭 시 count 1 증가
setCount(count + 1);
}
return <button onClick={handleClick}>{count}번 눌렀어요</button>;
}
const [count, setCount] = useState(0); 한 줄에 여러 가지가 들어 있어요. 하나씩 풀어볼게요.
useState(0): 0은 초기값이에요. 처음 화면에 나타날 때 count는 0이에요.useState는 요소가 두 개인 배열을 돌려줘요. 첫 번째는 현재 값, 두 번째는 그 값을 바꾸는 함수예요.const [count, setCount] = ...는 배열에서 요소를 꺼내 이름을 붙이는 배열 구조 분해 문법이에요. 이름은 [something, setSomething] 형태로 짓는 게 관례예요.setCount(count + 1)을 호출하면 React가 새 값을 저장하고, 컴포넌트를 다시 실행(리렌더링)해서 화면을 갱신해요.function handleClick() {
setCount(count + 1);
console.log(count); // 여전히 이전 값
}
state는 그 순간의 사진(스냅샷) 같아요. 이번 렌더링에서 count가 0이라면, 이 렌더링 안에서는 끝까지 0이에요. setCount는 "다음 렌더링에서는 이 값으로 그려줘"라는 요청일 뿐이에요.
새 값을 바로 써야 한다면 변수에 담아서 쓰면 돼요.
const nextCount = count + 1;
setCount(nextCount);
console.log(nextCount); // 새 값
age가 42일 때, 버튼 한 번에 3씩 올리고 싶어서 이렇게 썼다고 해볼게요.
function handleClick() {
setAge(age + 1); // age가 42라서 setAge(43)
setAge(age + 1); // 여전히 age는 42라서 setAge(43)
setAge(age + 1); // 마찬가지로 setAge(43)
}
결과는 45가 아니라 43이에요. 위에서 본 스냅샷 때문에, 이 함수가 실행되는 동안 age는 계속 42이기 때문이에요.
이럴 때는 값 대신 "이렇게 계산해 줘"라는 함수를 넘겨요.
function handleClick() {
setAge(a => a + 1); // 42 → 43
setAge(a => a + 1); // 43 → 44
setAge(a => a + 1); // 44 → 45
}
a => a + 1은 "직전에 계산된 값(a)을 받아서 1을 더한 값을 돌려줘"라는 뜻이에요. React는 이렇게 넘긴 함수들을 순서대로 쌓아 두었다가(큐), 다음 렌더링 때 차례로 실행해요. 이런 함수를 업데이터 함수라고 불러요. 인자 이름은 age의 첫 글자 a처럼 짓거나 prevAge처럼 지어도 돼요.
항상 이렇게 써야 하나요? 공식 문서에 따르면 대부분의 경우 둘은 차이가 없어요. 한 번의 이벤트 안에서 같은 state를 여러 번 바꿀 때 필요해요. 헷갈리면 항상 업데이터 함수로 써도 문제는 없어요.
state에는 숫자와 문자뿐 아니라 객체나 배열도 넣을 수 있어요. 다만 안에 든 값을 직접 수정하면 안 돼요.
const [form, setForm] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
});
// 잘못된 방식: 기존 객체 직접 수정
form.firstName = 'Taylor';
// 올바른 방식: 새 객체를 만들어 교체
setForm({
...form,
firstName: 'Taylor',
});
왜 그럴까요? React는 set 함수가 호출될 때 "이전 값과 새 값이 같은 것인가?"를 비교하고, 같다고 판단하면 화면을 다시 그리지 않아요. 기존 객체를 직접 고친 뒤 그 객체를 그대로 넘기면 React 눈에는 "같은 객체"라서 업데이트가 무시돼요. 새 객체를 만들어서 넘겨야 "바뀌었다"고 알아차려요. 그래서 state는 읽기 전용으로 다룬다고 말해요. 이런 성질을 불변성(immutability)이라고 해요.
...form은 뭔가요? 자바스크립트의 전개 문법(spread syntax) 이에요. 객체 안의 내용물을 풀어서 펼쳐 놓는다고 생각하면 돼요.
{ ...form, firstName: 'Taylor' }
이 코드는 "form 안의 모든 값(firstName, lastName)을 새 객체에 복사하고, firstName만 'Taylor'로 덮어쓴 새 객체를 만들어 줘"라는 뜻이에요. 같은 이름의 값이 있으면 뒤에 쓴 값이 앞의 값을 덮어써요.
배열도 같은 원리예요. 전개 문법으로 기존 항목을 펼치고, 그 뒤에 새 항목을 붙인 새 배열을 만들어요.
const [todos, setTodos] = useState([]);
// 잘못된 방식: 기존 배열 직접 수정
todos.push(newTodo);
// 올바른 방식: 기존 항목을 펼치고 새 항목을 뒤에 붙인 새 배열
setTodos([...todos, newTodo]);
초기값을 만드는 데 시간이 걸리는 함수가 있다면, 괄호를 붙이느냐 마느냐가 중요해요.
// 렌더링마다 함수 실행 (결과는 첫 렌더링에서만 사용)
const [todos, setTodos] = useState(createInitialTodos());
// 함수 자체를 전달: 처음 한 번만 실행
const [todos, setTodos] = useState(createInitialTodos);
초기값은 첫 렌더링에서만 쓰이는데, 괄호를 붙이면(createInitialTodos()) 화면을 다시 그릴 때마다 함수가 실행돼요. 결과는 버려지는데도요. 괄호 없이 함수 자체를 넘기면 React가 처음 한 번만 호출해요.
set을 호출한 직후에 값을 읽음: 위의 알아둘 점 1처럼 이전 값이 나와요.set을 호출: 같은 객체로 보여서 화면이 바뀌지 않아요.onClick={handleClick()}처럼 괄호를 붙임: 클릭할 때가 아니라 렌더링 중에 함수가 실행돼요. 그 안의 set이 다시 렌더링을 일으키고, 이것이 무한 반복되어 Too many re-renders 에러가 나요. onClick={handleClick}으로 함수 자체를 넘겨야 해요.useEffect — React 밖의 것과 맞춰주기컴포넌트를 React 밖의 시스템과 맞춰주는(동기화하는) Hook이에요. 화면에 반영된 뒤 실행할 코드를 등록하고, 그 코드가 한 일을 정리하는 방법도 함께 등록해요.
"React 밖의 시스템"은 예를 들면 이런 것들이에요.
setInterval)window.addEventListener)이런 것들은 React가 알아서 켜고 꺼주지 않아서, 직접 "켜고 끄는" 코드를 써야 해요. 그 자리가 useEffect예요.
핵심은 이거예요. useEffect는 외부 시스템과 연결할 때 쓰는 Hook이고, 그게 아니라면 필요 없을 수도 있어요.
import { useState, useEffect } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
// 타이머 시작: 1초마다 seconds 1 증가
const id = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
// 정리(cleanup): 타이머 중지
return () => clearInterval(id);
}, []);
return <h1>{seconds}초</h1>;
}
useEffect에는 두 가지를 넘겨요.
return하면, 그 함수가 cleanup 함수(정리 함수)가 돼요. "setup에서 켠 것을 끄는 코드"를 넣어요.[]예요. 아래에서 자세히 설명할게요.setSeconds(s => s + 1)은 앞에서 배운 업데이터 함수예요. 여기서 setSeconds(seconds + 1)로 쓰면 seconds를 의존성 배열에 넣어야 하고, 그러면 값이 바뀔 때마다 타이머를 껐다가 다시 켜게 돼요. 업데이터 함수를 쓰면 그럴 필요가 없어요.
cleanup은 "setup이 한 일을 되돌리는 코드"예요. 켰으면 끄고, 연결했으면 끊고, 구독했으면 해제해요.
| 모양 | 실행 시점 |
|---|---|
[a, b] | 처음 한 번, 그리고 a 또는 b가 바뀔 때마다 |
[] | 처음 한 번만 |
| 생략 | 화면이 그려질 때마다 (대부분 원하지 않는 동작) |
의존성은 내가 고르는 게 아니라, 코드가 정해줘요. Effect 안에서 쓰는 props, state, 그리고 컴포넌트 안에서 만든 변수와 함수는 전부 배열에 넣어야 해요.
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]); // serverUrl, roomId 변경 시 재연결
}
이 코드는 serverUrl과 roomId를 안에서 쓰기 때문에 둘 다 의존성이에요. 둘 중 하나가 바뀌면 이전 연결을 끊고(cleanup) 새 값으로 다시 연결해요(setup). 의존성을 빼먹으면 ESLint가 경고해 주는데, 이 경고를 eslint-disable로 끄면 React에게 거짓말을 하는 셈이라 버그로 이어지기 쉬워요.
개발 환경의 Strict Mode에서는 React가 setup → cleanup → setup을 한 번 더 실행해요. cleanup이 setup을 제대로 되돌리는지 확인하는 테스트예요. 실제 배포 환경에서는 한 번만 실행돼요.
위의 타이머 예제는 두 번 실행돼도 문제가 없어요. cleanup에서 타이머를 끄기 때문에 타이머가 겹쳐서 돌지 않아요. 만약 두 번 실행했을 때 문제가 생긴다면, cleanup이 빠졌다는 신호예요.
Effect 안에서 서버 데이터를 가져오는 경우가 많아요. 이때 조심할 점이 있어요.
useEffect(() => {
let ignore = false;
fetchBio(person).then(result => {
if (!ignore) {
setBio(result);
}
});
return () => {
// 이전 요청의 응답 무시
ignore = true;
};
}, [person]);
person을 빠르게 여러 번 바꾸면 요청도 여러 개 나가요. 그런데 응답이 요청한 순서대로 도착한다는 보장은 없어요. 나중에 보낸 요청의 응답이 먼저 오고, 이전 요청의 응답이 나중에 오면 화면이 이전 사람의 정보로 덮여 버려요. 이런 문제를 경쟁 상태(race condition) 라고 해요.
cleanup에서 ignore = true로 바꿔 두면, 이미 지나간 요청의 응답은 무시하게 돼요.
다만 공식 문서는 Effect 안에서 직접 데이터를 가져오는 방식은 코드가 반복되고 캐싱 같은 최적화를 붙이기 어렵다고 말해요. 프레임워크를 쓴다면 프레임워크의 데이터 가져오기 기능을, 아니라면 TanStack Query 같은 라이브러리를 쓰는 걸 권해요.
외부 시스템과 동기화하는 게 아니라면 Effect가 필요 없을 수 있어요. 신입 때 가장 많이 하는 실수가 이 부분이에요. 공식 문서는 Effect가 필요 없는 흔한 경우를 두 가지로 꼽아요.
1) 화면에 보여줄 데이터를 만들 때는 Effect가 필요 없어요.
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 잘못된 방식: 중복 state와 불필요한 Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 올바른 방식: 렌더링 중 바로 계산
const fullName = firstName + ' ' + lastName;
// ...
}
첫 번째 코드는 복잡할 뿐 아니라 비효율적이에요. firstName이 바뀌면 React는 먼저 컴포넌트를 렌더링하고(이때 fullName은 아직 이전 값이에요), 화면에 반영한 뒤에 Effect를 실행해요. 그런데 Effect가 곧바로 state를 또 바꾸기 때문에, 이 과정이 처음부터 다시 시작돼요. 오래된 값으로 한 번 그리고, 새 값으로 또 그리는 셈이에요.
원칙은 간단해요. 기존 props나 state로 계산할 수 있는 값은 state에 넣지 말고, 렌더링 중에 계산하세요. 코드가 더 빠르고, 더 짧고, state끼리 어긋나는 버그도 생기지 않아요. 계산이 정말 느리다면 Effect가 아니라 3-2에서 배울 useMemo로 캐시해요.
2) 사용자 이벤트를 처리할 때는 Effect가 필요 없어요.
예를 들어 "구매" 버튼을 눌렀을 때 서버에 요청을 보내고 알림을 띄운다고 해볼게요. 버튼의 클릭 이벤트 핸들러 안에서는 무슨 일이 일어났는지 정확히 알 수 있어요. 하지만 Effect가 실행되는 시점에는 사용자가 무엇을 했는지(예를 들어 어떤 버튼을 눌렀는지) 알 수 없어요. 그래서 사용자 동작에 대한 반응은 대개 해당 이벤트 핸들러에 써요.
Effect와 이벤트 핸들러 중 무엇을 써야 할지 헷갈린다면, 이렇게 자문해 보세요. "이 코드는 왜 실행되어야 하지?"
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// 올바른 방식: 폼이 화면에 보여졌기 때문에 실행하는 로직
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
function handleSubmit(e) {
e.preventDefault();
// 올바른 방식: 버튼을 눌렀기 때문에 실행하는 로직
post('/api/register', { firstName, lastName });
}
// ...
}
분석용 이벤트는 "폼이 화면에 보여졌기 때문에" 보내는 것이라 Effect가 맞아요. 반면 가입 요청은 "버튼을 눌렀기 때문에" 보내는 것이라 이벤트 핸들러에 써야 해요.
| 하고 싶은 일 | Effect 대신 |
|---|---|
| 다른 state나 props로 계산할 수 있는 값 만들기 | 렌더링 중 바로 계산 |
| 비싼 계산 결과 캐시하기 | useMemo |
| prop이 바뀔 때 컴포넌트의 모든 state 초기화하기 | 컴포넌트에 다른 key 전달 |
| 사용자 동작에 반응하기 | 이벤트 핸들러 |
| 외부 스토어 구독하기 | useSyncExternalStore (4-4) |
더 자세한 내용은 공식 문서의 Effect가 필요하지 않을 수도 있습니다를 참고하세요.
useRef — 화면과 상관없는 값 기억하기렌더링에 쓰지 않는 값을 기억해 두는 상자예요. { current: 값 } 모양의 객체를 돌려주고, 화면이 다시 그려져도 같은 객체가 유지돼요. current를 바꿔도 화면은 다시 그려지지 않아요.
useState | useRef | |
|---|---|---|
| 값이 바뀌면 | 화면을 다시 그려요 | 아무 일도 일어나지 않아요 |
| 값을 바꾸는 방법 | set 함수 호출 | ref.current = 값으로 직접 수정 |
| 렌더링 중에 읽기 | 가능해요 | 하지 않아야 해요 |
| 주로 쓰는 곳 | 화면에 보여줄 값 | 화면과 무관한 값, DOM 요소 |
기준은 간단해요. 화면에 보여줄 값이면 state, 그렇지 않으면 ref예요.
function Stopwatch() {
const intervalRef = useRef(0);
function handleStart() {
// 타이머 ID를 ref에 보관
intervalRef.current = setInterval(() => {
// ...
}, 1000);
}
function handleStop() {
// 보관해 둔 ID로 타이머 중지
clearInterval(intervalRef.current);
}
// ...
}
타이머 ID는 화면에 보여줄 값이 아니에요. 그런데 화면이 다시 그려져도 사라지면 안 돼요. 일반 변수는 매번 초기화되고, state는 바꿀 때마다 불필요하게 화면을 다시 그려요. 이런 값이 ref에 딱 맞아요.
DOM은 브라우저가 화면에 그리는 HTML 요소 객체예요. 입력창에 포커스를 주거나 스크롤을 옮기는 것처럼, React 방식으로는 표현하기 어려운 동작에 ref를 써요.
import { useRef } from 'react';
function Form() {
const inputRef = useRef(null);
function handleClick() {
// 입력창에 포커스
inputRef.current.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>입력창으로 이동</button>
</>
);
}
순서는 이래요.
useRef(null)로 빈 ref를 만들어요.ref={inputRef}를 붙여요.inputRef.current에 넣어줘요.inputRef.current.focus()처럼 DOM 요소의 기능을 직접 호출해요.요소가 화면에서 사라지면 React가 current를 다시 null로 돌려놔요.
ref.current를 읽거나 쓰지 마세요function MyComponent() {
// 잘못된 방식: 렌더링 중 ref 쓰기
myRef.current = 123;
// 잘못된 방식: 렌더링 중 ref 읽기
return <h1>{myOtherRef.current}</h1>;
}
컴포넌트는 같은 입력(props, state)이면 같은 화면을 만들어야 해요. 그런데 ref는 React가 변화를 추적하지 않아서, 렌더링 중에 읽으면 결과를 예측할 수 없게 돼요. ref는 이벤트 핸들러나 useEffect 안에서 읽고 쓰세요. 렌더링 중에 꼭 필요한 값이라면 state가 맞아요.
// React 19: ref를 일반 prop처럼 받음
function MyInput({ ref }) {
return <input ref={ref} />;
}
function Form() {
const inputRef = useRef(null);
return (
<>
<MyInput ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>포커스</button>
</>
);
}
기본적으로 내가 만든 컴포넌트는 안쪽 DOM 요소를 바깥에 공개하지 않아요. 그래서 ref를 prop으로 받아서 안쪽 요소에 직접 넘겨줘야 해요. React 19부터는 ref를 다른 prop처럼 받을 수 있어요. React 18 이하에서는 forwardRef라는 함수로 컴포넌트를 감싸야 했어요. 예전 자료에서 forwardRef가 보여도 당황하지 마세요.
{ref.current}를 JSX에 써도 값이 바뀌었을 때 화면이 갱신되지 않아요.ref.current를 읽거나 씀: 위에서 설명한 이유로 피해야 해요.Cannot read properties of null 에러가 나요.useContext — 멀리 있는 값을 바로 읽기멀리 있는 부모 컴포넌트가 내려준 값을, 중간 컴포넌트를 거치지 않고 바로 읽는 Hook이에요.
앱 맨 위에 있는 "다크 모드 여부"를 저 깊은 곳의 버튼이 알아야 한다고 해볼게요. props로만 전달하면 이렇게 돼요.
App → Form → Panel → Button
Form과 Panel은 테마를 쓰지도 않는데, 아래로 전달하기 위해 props를 받아서 그대로 넘겨야 해요. 이렇게 props를 여러 단계에 걸쳐 전달하는 것을 props drilling이라고 부르고, 컴포넌트가 깊어질수록 번거로워져요. Context를 쓰면 이 중간 단계를 건너뛸 수 있어요.
import { createContext, useContext } from 'react';
// 1단계: Context 생성 (기본값 'light')
const ThemeContext = createContext('light');
// 2단계: 위쪽 부모에서 값 제공 (이 안쪽 전체가 'dark'를 읽을 수 있음)
function MyApp() {
return (
<ThemeContext value="dark">
<Form />
</ThemeContext>
);
}
// 중간 컴포넌트: theme을 props로 받지 않음
function Form() {
return (
<Panel>
<Button>가입하기</Button>
</Panel>
);
}
function Panel({ children }) {
return <section>{children}</section>;
}
function Button({ children }) {
// 3단계: 값 읽기 (가장 가까운 위쪽 제공자의 값)
const theme = useContext(ThemeContext);
return <button className={'button-' + theme}>{children}</button>;
}
useContext(ThemeContext)는 트리에서 가장 가까운 위쪽의 <ThemeContext value=...>를 찾아서 그 값을 돌려줘요.createContext('light')에 넣은 기본값이 쓰여요.<ThemeContext value="dark">로 쓰고, React 18 이하에서는 <ThemeContext.Provider value="dark">로 썼어요. 예전 자료를 볼 때 참고하세요.Context는 값을 전달하는 통로예요. 값을 바꾸는 건 state가 해요.
function MyApp() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext value={theme}>
<Form />
<button onClick={() => setTheme('dark')}>다크 모드로 변경</button>
</ThemeContext>
);
}
theme이 바뀌면 이 Context를 읽는 모든 컴포넌트가 새 값으로 다시 그려져요.
useContext를 호출하는 컴포넌트와 같은 곳이거나 그 아래에 있음: <ThemeContext value=...>는 반드시 useContext를 호출하는 컴포넌트보다 위에 있어야 해요. 같은 컴포넌트 안에서 감싼 것은 그 컴포넌트의 useContext에 영향을 주지 않아요.value를 빼먹거나 이름을 잘못 씀: <ThemeContext theme={theme}>처럼 쓰면 value={undefined}를 준 것과 같아서, 읽는 쪽에서 undefined를 받아요. 이름은 꼭 value여야 해요.앞의 네 가지 Hook만으로도 대부분의 화면을 만들 수 있어요. 그런데 앱이 커지면 이런 고민이 생겨요.
이 섹션의 Hook들은 바로 이런 고민을 풀어주는 도구예요. 예제에 나오는 filterTodos, post, addToCart, updateCartAction과 /api/... 같은 API 경로는 설명을 위해 가정한 예시예요.
| Hook | 이런 문제를 풀어요 |
|---|---|
useReducer | state를 바꾸는 방법이 많고 복잡할 때 |
useMemo, useCallback | 불필요한 계산과 리렌더링을 건너뛰고 싶을 때 |
useTransition | 급하지 않은 업데이트가 급한 동작을 방해할 때, 비동기 작업의 진행 상태를 보여줄 때 |
useDeferredValue | 느린 화면 영역 때문에 입력이 버벅일 때 |
useActionState | 서버 요청 같은 비동기 작업의 결과를 state로 관리할 때 |
useReducer — state 바꾸는 방법을 한곳에 모으기useState처럼 state를 만들어 주지만, state를 바꾸는 방법을 컴포넌트 밖의 함수 하나(reducer)에 모아 두는 Hook이에요.
useState에서는 이벤트 핸들러마다 setXxx(...)로 직접 값을 바꿔요. state가 여러 값으로 이뤄져 있고 바꾸는 경우도 여러 가지라면, 바꾸는 코드가 컴포넌트 곳곳에 흩어져요. useReducer를 쓰면 "무슨 일이 일어났는지"만 알려주고, "그래서 state가 어떻게 바뀌는지"는 reducer 한 곳에서 정해요.
import { useReducer } from 'react';
// reducer: 현재 state와 action을 받아 다음 state 반환
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
return { ...state, age: state.age + 1 };
}
case 'changed_name': {
return { ...state, name: action.nextName };
}
}
throw Error('알 수 없는 action: ' + action.type);
}
function Form() {
const [state, dispatch] = useReducer(reducer, { name: 'Taylor', age: 42 });
function handleButtonClick() {
// "나이가 1 증가했다"는 사실 전달
dispatch({ type: 'incremented_age' });
}
function handleInputChange(e) {
// "이름이 바뀌었다"는 사실과 새 이름 전달
dispatch({ type: 'changed_name', nextName: e.target.value });
}
return (
<>
<input value={state.name} onChange={handleInputChange} />
<button onClick={handleButtonClick}>나이 +1</button>
<p>{state.name}님은 {state.age}살이에요.</p>
</>
);
}
용어를 하나씩 풀어볼게요.
useReducer(reducer, 초기값)은 [state, dispatch]를 돌려줘요. state는 현재 값, dispatch는 "무슨 일이 일어났는지"를 React에 알리는 함수예요.dispatch에 넘기는 객체예요. 일어난 일을 담고 있어요. 관례상 type으로 종류를 구분하고, 필요하면 nextName처럼 추가 정보를 같이 넣어요.dispatch를 호출하면 React가 reducer(현재 state, action)을 호출하고, 그 반환값을 다음 state로 저장한 뒤 화면을 다시 그려요.
reducer도 useState와 같은 규칙을 지켜야 해요. state를 직접 고치지 않고, 새 객체를 만들어서 반환해요. 위 코드의 { ...state, age: state.age + 1 }은 2-1에서 배운 전개 문법으로 "기존 값을 전부 복사하고 age만 바꾼 새 객체"를 만든 거예요. reducer는 렌더링 중에 실행되기 때문에 1-3에서 본 것처럼 계산만 해야 해요.
useState와 뭐가 다른가요?useState | useReducer | |
|---|---|---|
| 값을 바꾸는 방식 | setAge(43)처럼 새 값을 직접 전달 | dispatch({ type: ... })로 일어난 일만 전달 |
| 바꾸는 로직의 위치 | 이벤트 핸들러마다 | reducer 한 곳 |
| 잘 맞는 경우 | 값 하나를 간단히 바꿀 때 | 여러 값이 함께 바뀌고, 바뀌는 경우가 여러 가지일 때 |
값 하나를 간단히 바꾸는 정도라면 useState로 충분해요.
state.age++; return state;처럼 쓰면 같은 객체를 돌려준 것이라 React가 "바뀐 게 없다"고 판단해서 화면이 안 바뀌어요....state를 빼먹음: 바꾼 값 하나만 들어 있는 객체를 반환하면 나머지 값이 undefined가 돼요.return을 빼먹거나 type을 오타 냄: state 전체가 undefined가 될 수 있어요. 위 예제처럼 switch 밖에서 throw Error(...)를 해두면 원인을 빨리 찾을 수 있어요.dispatch 직후에 state를 읽음: useState와 똑같이 이전 값이 나와요(스냅샷).useMemo와 useCallback — 계산과 함수를 기억해 두기렌더링과 렌더링 사이에 무언가를 캐시(저장해 두고 재사용)하는 Hook이에요.
useMemo: 계산의 결과를 캐시해요.useCallback: 함수 자체를 캐시해요.React Compiler를 사용하면 값과 함수, 그리고 컴포넌트(
memo에 해당하는 최적화)까지 컴파일러가 자동으로 캐시해 줘요. 그래서 직접 쓸 일이 줄어들어요. 그래도 기존 코드에서 자주 만나니 개념은 알아두세요.
이 Hook들이 없어도 앱은 똑같이 동작해요. 공식 문서도 성능 최적화로만 의존하라고 강조해요. 이 Hook을 지웠을 때 코드가 동작하지 않는다면, 그 근본 원인을 먼저 고쳐야 한다는 뜻이에요. React가 특별한 이유로 캐시를 버릴 수도 있기 때문이에요. 그리고 공식 문서는 성능 문제가 생기기 전에 미리 최적화하지 말라고도 당부해요.
두 가지 사실을 알아야 이 Hook의 쓰임새가 이해돼요.
1) 부모가 다시 렌더링되면, 자식도 기본적으로 전부 다시 렌더링돼요. (1-1에서 본 내용이에요.) 이게 느려지는 원인이 될 수 있어요. 이때 자식 컴포넌트를 memo로 감싸면, props가 이전과 같을 때는 다시 렌더링하지 않도록 할 수 있어요. memo는 Hook이 아니라 컴포넌트를 감싸는 함수예요.
import { memo } from 'react';
const Greeting = memo(function Greeting({ name }) {
return <h1>안녕하세요, {name}!</h1>;
});
이 Greeting은 부모가 name이 아닌 다른 state(예를 들어 address)를 바꿔서 다시 렌더링돼도, name prop이 그대로라면 다시 렌더링되지 않아요. 다만 memo는 부모가 넘겨주는 props에만 관련된 최적화예요. 컴포넌트 자신의 state가 바뀌거나, 컴포넌트가 사용하는 context가 바뀌면 memo로 감싸도 다시 렌더링돼요.
2) 그런데 객체, 배열, 함수는 렌더링마다 새로 만들어져요. React는 memo로 감싼 컴포넌트의 props를 각 prop마다 Object.is로 비교해요. 내용이 똑같아도 새로 만들어진 객체는 "다른 값"이에요.
console.log(Object.is(3, 3)); // true
console.log(Object.is({}, {})); // false: 모양이 같아도 서로 다른 객체
그래서 부모가 렌더링 중에 만든 객체나 일반 함수를 자식에게 넘기면, props가 매번 달라져서 memo가 아무 효과도 없어요. 공식 문서도 이 경우 memo는 완전히 쓸모없다고 말해요. 이때 useMemo와 useCallback으로 내용이 안 바뀌었으면 같은 값을 유지하게 만들어요.
memo가 값어치 있는 경우는 같은 props로 자주 다시 렌더링되고, 그 렌더링 비용이 클 때예요. 다시 렌더링해도 체감되는 지연이 없다면 memo는 필요 없어요.
useMemo 사용법import { useMemo } from 'react';
function TodoList({ todos, tab, theme }) {
// todos나 tab이 바뀔 때만 다시 계산
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
// ...
}
useEffect와 같은 규칙이에요. 계산 안에서 쓰는 값을 전부 넣어요.React는 렌더링마다 의존성을 이전 값과 비교해요. 모두 같으면 계산을 건너뛰고 이전 결과를 그대로 돌려주고, 하나라도 다르면 다시 계산해요. 위 코드에서는 theme만 바뀌었을 때 filterTodos가 다시 실행되지 않아요. visibleTodos가 같은 배열로 유지되니 memo로 감싼 자식에게 넘겨도 효과가 있어요.
언제 써야 하나요? 공식 문서가 꼽는 경우는 세 가지예요.
memo로 감싼 자식 컴포넌트에 prop으로 넘기는 값일 때"얼마나 느려야 느린 계산인가요?"라는 질문에 문서는 이렇게 답해요. 수천 개 이상의 항목을 만들거나 순회하는 게 아니라면 대개 비싼 계산이 아니에요. 궁금하면 console.time으로 직접 재보고, 합계가 대략 1ms 이상일 때 고려해 보세요. 개발 모드의 측정값은 정확하지 않으니 실제 배포용 빌드로 확인하는 것이 좋아요.
console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array'); // 걸린 시간 출력
useCallback 사용법import { memo, useCallback } from 'react';
const ShippingForm = memo(function ShippingForm({ onSubmit }) {
// ...
});
function ProductPage({ productId, referrer, theme }) {
// productId나 referrer가 바뀔 때만 새 함수 사용
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}, [productId, referrer]);
return (
<div className={theme}>
<ShippingForm onSubmit={handleSubmit} />
</div>
);
}
theme이 바뀌어서 ProductPage가 다시 렌더링돼도 handleSubmit은 같은 함수로 유지돼요. 그래서 ShippingForm은 "props가 그대로네" 하고 다시 렌더링하지 않아요. useCallback 없이 그냥 함수를 선언하면 렌더링마다 새 함수가 만들어져서 memo가 아무 효과 없어요.
useMemo와 useCallback의 차이useMemo | useCallback | |
|---|---|---|
| 캐시하는 것 | 함수를 호출한 결과 | 함수 자체 |
| 함수 호출 | React가 렌더링 중 호출 | 호출하지 않고 돌려줌 |
공식 문서는 useCallback(fn, deps)를 useMemo(() => fn, deps)와 같다고 설명해요. 함수를 캐시할 때 useMemo로 쓰면 함수를 한 번 더 감싸야 해서 번거로우니 useCallback이 따로 있는 거예요.
memo가 잘 동작하게 props 줄이기memo의 효과를 제대로 보려면 props가 바뀌는 횟수를 최소화해야 해요. 공식 문서가 권하는 방법은 이래요.
useCallback으로 캐시해요.function Page() {
const [name, setName] = useState('Taylor');
const [age, setAge] = useState(42);
// 객체를 만들어 넘기는 대신, 필요한 개별 값 전달
return <Profile name={name} age={age} />;
}
const Profile = memo(function Profile({ name, age }) {
// ...
});
공식 문서는 메모이제이션을 붙이기 전에 이런 원칙들을 먼저 지켜보라고 해요. 이것만으로 해결되는 경우가 많고, 코드도 이해하기 쉬워져요.
children으로 JSX를 받게 만들어요. 그러면 감싸는 컴포넌트가 자기 state를 바꿔도 children은 다시 렌더링하지 않아도 된다는 걸 React가 알아요.그래도 특정 동작이 느리다면, React 개발자 도구의 프로파일러로 어떤 컴포넌트가 가장 이득을 볼지 확인한 뒤 필요한 곳에만 메모이제이션을 추가하세요.
memo를 붙였는데 props가 매번 새로 만들어짐: 렌더링 중에 만든 객체나 일반 함수를 넘기면 memo가 소용없어요.memo가 아닌데 useCallback만 씀: 자식은 어차피 다시 렌더링돼서 효과가 없어요.useMemo에서 객체를 반환하는데 undefined가 됨: 화살표 함수의 {는 객체가 아니라 함수 본문의 시작으로 해석돼요.// 잘못된 방식: 중괄호가 객체가 아니라 함수 본문으로 해석
const options = useMemo(() => { matchMode: 'whole-word' }, []);
// 올바른 방식: return 명시
const options = useMemo(() => {
return { matchMode: 'whole-word' };
}, []);
다음 세 Hook(useTransition, useDeferredValue, useActionState)은 모두 세 가지 개념 위에 있어요. 이 개념을 먼저 잡고 가요.
결과가 나올 때까지 시간이 걸리는 작업을, 기다리는 동안 다른 일을 멈추지 않고 처리하는 방식이에요.
console.log('1');
setTimeout(() => console.log('2'), 1000); // 1초 뒤 실행 예약
console.log('3');
// 출력 순서: 1 → 3 → 2
2가 나중에 나오는 건, 1초를 기다리는 동안 자바스크립트가 멈추지 않고 3을 먼저 실행했기 때문이에요. 자바스크립트는 기본적으로 한 번에 하나만 실행하기 때문에, 서버 응답을 3초 기다리는 동안 코드가 멈춘다면 화면도 3초간 얼어붙을 거예요. 그래서 시간이 걸리는 일(서버 요청, 타이머, 파일 읽기, DB 조회 등)은 비동기로 처리해요.
가장 읽기 쉬운 방법은 async/await예요.
async function loadData() {
// 응답이 올 때까지 이 함수 안에서만 대기
const res = await fetch('/api/data');
const json = await res.json();
return json;
}
async 함수는 항상 Promise를 반환해요. await는 "이 작업이 끝날 때까지 이 함수 안에서는 기다려라"라는 뜻이고, 함수 밖의 다른 코드는 멈추지 않아요.
"이 state 업데이트는 급하지 않으니, 더 급한 일(타이핑, 클릭 등)을 먼저 처리해도 돼요"라고 React에게 알려주는 표시예요.
업데이트를 두 종류로 나눌 수 있어요.
Transition으로 표시된 업데이트는 중단될 수 있어요. 오래 걸리는 렌더링을 하는 도중에 사용자가 다시 입력하면, React는 하던 일을 멈추고 입력부터 처리한 뒤 다시 시작해요.
Transition 안에서 실행되는 함수를 Action이라고 불러요. 비동기 함수여도 돼요. React는 Action이 실행되는 동안을 추적해서 "진행 중인지"(isPending)를 알려줘요.
이제 이 세 가지를 알았으니, 각 Hook이 무엇을 해주는지 쉽게 이해할 수 있어요.
useTransition — 급하지 않은 업데이트 표시하기일부 state 업데이트를 Transition(급하지 않은 업데이트)으로 표시하고, 그 업데이트가 진행 중인지(isPending) 알려주는 Hook이에요.
import { useState, useTransition } from 'react';
function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('about');
function selectTab(nextTab) {
// 이 안에서 실행한 state 업데이트는 Transition으로 처리
startTransition(() => {
setTab(nextTab);
});
}
// ...
}
useTransition()은 [isPending, startTransition]을 돌려줘요.startTransition(action)에 넘긴 함수(Action)는 바로 실행되고, 그 안에서 일어난 state 업데이트가 Transition으로 표시돼요.isPending은 Transition이 진행 중일 때 true예요. 로딩 표시를 할 때 써요.Transition은 사용자 경험을 이렇게 바꿔줘요. 탭을 눌렀는데 그 탭 화면이 무거워서 오래 걸리는 상황을 떠올려 보세요. Transition이 없으면 그 동안 화면이 굳어요. Transition으로 표시하면 탭을 누른 뒤 마음이 바뀌어 다른 탭을 눌러도 두 번째 클릭이 바로 처리돼요. 첫 번째 업데이트가 끝나기를 기다리지 않아요.
function TabButton({ action, children, isActive }) {
const [isPending, startTransition] = useTransition();
if (isActive) {
return <b>{children}</b>;
}
// 대기 중 표시
if (isPending) {
return <b className="pending">{children}</b>;
}
return (
<button onClick={() => {
startTransition(async () => {
await action();
});
}}>
{children}
</button>
);
}
<Suspense>는 데이터를 기다리는 동안 대체 화면(fallback)을 보여주는 React 기능이에요. 탭을 눌렀을 때 그 탭이 데이터를 기다리느라 멈추면(suspend), 탭 전체가 스피너로 바뀌어 버릴 수 있어요. 이때 탭 전환을 Transition으로 표시하면 이미 보이던 내용을 스피너로 바꾸지 않고, 새 내용이 준비될 때까지 기다려요. 그 사이에 isPending으로 버튼에 진행 중 표시를 할 수 있어요.
Action 안에서 await을 쓸 수 있어요. 이때 주의할 점이 있어요.
startTransition(async () => {
const savedQuantity = await updateQuantity(newQuantity);
// await 이후의 state 업데이트는 startTransition으로 한 번 더 감싸기
startTransition(() => {
setQuantity(savedQuantity);
});
});
await 이후에 일어나는 state 업데이트는 Transition으로 인식되지 않아요. 그래서 startTransition으로 한 번 더 감싸야 해요. 공식 문서는 이걸 자바스크립트의 한계 때문에 생긴 현재의 제약이라고 설명해요.
입력창(controlled input)의 state에는 쓸 수 없어요. 입력은 즉시 반영돼야 하는데 Transition은 "급하지 않은" 업데이트라서 맞지 않아요.
function handleChange(e) {
// 잘못된 방식: 입력창 state를 Transition으로 처리
startTransition(() => {
setText(e.target.value);
});
}
타이핑에 맞춰 무거운 화면을 갱신하고 싶다면, 입력용 state와 화면용 state를 따로 두거나 다음에 나올 useDeferredValue를 써요.
startTransition에 넘긴 함수는 미뤄지지 않고 바로 실행돼요. 그 함수가 실행되는 동안 일어난 state 업데이트만 Transition이 돼요. setTimeout 안에서 호출한 set은 Transition이 아니에요. 필요하면 setTimeout 안쪽에서 startTransition을 호출하세요.
여러 Action의 순서는 보장되지 않아요. 빠르게 여러 번 요청하면 나중 요청의 응답이 먼저 도착해 화면이 엉킬 수 있어요. 이런 경우를 다루는 useActionState가 뒤에 나와요.
컴포넌트 밖에서 써야 한다면 react에서 가져오는 startTransition 함수를 직접 쓰세요. 다만 isPending은 없어요.
set 함수에 접근할 수 있을 때만 쓸 수 있어요. props로 받은 값 때문에 업데이트를 미루고 싶다면 useDeferredValue를 써요.
await 이후의 업데이트를 다시 감싸지 않음setTimeout 안의 set이 Transition이라고 생각함useDeferredValue — 값이 천천히 따라오게 하기어떤 값의 "뒤따라오는 버전"을 만들어서, 화면의 일부를 나중에 갱신하게 하는 Hook이에요.
입력창에 글자를 칠 때마다 아주 긴 목록을 다시 그리는 화면이 있다고 해볼게요. 목록이 느려서 타이핑이 버벅여요.
import { memo, useState, useDeferredValue } from 'react';
const SlowList = memo(function SlowList({ text }) {
// ... 렌더링이 느린 긴 목록
});
function App() {
const [text, setText] = useState('');
// text를 뒤따라오는 값
const deferredText = useDeferredValue(text);
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<SlowList text={deferredText} />
</>
);
}
동작은 이래요.
text가 즉시 바뀌어서 입력창은 바로 갱신돼요.deferredText는 아직 이전 값이라서, SlowList는 props가 그대로라 다시 그려지지 않아요(memo 덕분이에요).deferredText를 새 값으로 바꿔 SlowList를 다시 그려요.목록이 입력을 뒤따라오다가 따라잡는 모습이에요. 목록 자체가 빨라지는 건 아니에요. "이 부분은 덜 급하니까 입력을 먼저 처리해"라고 우선순위를 낮춰 주는 거예요.
memo가 꼭 필요해요. 타이핑할 때SlowList가memo가 아니면,deferredText가 이전 값이어도 부모가 다시 그려지면서SlowList도 같이 다시 그려져서 효과가 없어요.
검색 결과를 서버에서 받아오는 화면에서도 유용해요. 검색어를 바꿀 때 결과 영역이 로딩 화면으로 바뀌는 대신, 새 결과가 준비될 때까지 이전 결과를 그대로 보여줄 수 있어요.
const deferredQuery = useDeferredValue(query);
// 최신 검색어와 결과용 검색어가 다르면 이전 결과를 보여주는 중
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<SearchResults query={deferredQuery} />
</div>
);
새 결과를 기다리는 동안 이전 결과를 살짝 흐리게 해서, 지금 보이는 게 최신 결과가 아니라는 것도 알려줄 수 있어요. 주의할 점은 useDeferredValue가 네트워크 요청을 줄여주지는 않는다는 거예요. 미루는 것은 결과를 화면에 보여주는 것이에요.
useTransition과 뭐가 다른가요?useTransition | useDeferredValue | |
|---|---|---|
| 미루는 대상 | state를 바꾸는 코드(set 호출) | 값(props나 state) 자체 |
| 쓸 수 있는 조건 | set 함수에 접근할 수 있을 때 | 값만 있으면 돼요. props나 커스텀 Hook의 값도 가능해요 |
입력창 state는 Transition으로 감쌀 수 없으니, 입력 중 무거운 화면을 미뤄야 할 때는 useDeferredValue가 잘 맞아요.
debounce는 "사용자가 입력을 멈추고 일정 시간이 지나면 갱신"하는 방식이에요. useDeferredValue는 이와 달라요.
다만 렌더링이 아닌 작업, 예를 들어 서버 요청 횟수를 줄이는 데는 debounce가 여전히 유용해요.
useActionState — 비동기 작업의 결과를 state로 관리하기Action(서버 요청처럼 비동기이고 부수효과가 있는 작업)의 결과를 state로 관리하는 Hook이에요. "이전 state를 받아서 다음 state를 돌려주는 함수"를 등록해 두면, React가 호출 순서와 진행 상태를 관리해 줘요.
3-1의 useReducer와 닮았는데, 공식 문서는 이렇게 구분해요.
useReducer: UI의 state를 관리해요. reducer는 순수해야 해요.useActionState: Action의 state를 관리해요. reducer에서 서버 요청 같은 부수효과를 해도 돼요.즉 "부수효과를 써도 되는 useReducer"라고 생각하면 이해하기 쉬워요.
const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState);
| 이름 | 의미 |
|---|---|
reducerAction | 이전 state와 전달받은 값을 받아서 다음 state를 돌려주는 함수예요. 비동기 함수도 돼요 |
initialState | state의 초기값이에요 |
state | 현재 state예요. 처음에는 initialState이고, 이후에는 reducerAction이 마지막으로 반환한 값이에요 |
dispatchAction | reducerAction을 실행시키는 함수예요 |
isPending | 실행 중인 Action이 있는지 여부예요 |
import { useActionState, startTransition } from 'react';
function Checkout() {
const [count, dispatchAction, isPending] = useActionState(
// reducerAction: 이전 수량을 받아 서버에 추가 요청 후 새 수량 반환
async (prevCount) => {
return await addToCart(prevCount);
},
0 // 초기 수량
);
function handleClick() {
startTransition(() => {
dispatchAction();
});
}
return (
<button onClick={handleClick}>
티켓 추가 (현재 {count}장){isPending ? ' 처리 중...' : ''}
</button>
);
}
흐름은 이래요.
dispatchAction()이 호출돼요.reducerAction(이전 state, 넘긴 값)을 호출해요. 실행되는 동안 isPending은 true예요.reducerAction이 반환한 값이 새 state가 되고 화면이 갱신돼요.공식 문서에서는
reducerAction이 부수효과를 하도록 설계되어서, 개발 환경의 Strict Mode에서도 두 번 실행되지 않는다고 해요.
dispatchAction을 빠르게 여러 번 호출하면 React가 호출을 줄 세워서 하나씩 순서대로 실행해요. 앞 호출의 결과가 다음 호출의 prevState로 들어가야 하기 때문이에요. 그래서 요청에 1초가 걸리면, 네 번 누르면 4초가 걸려요. 공식 문서는 이게 의도된 설계라고 말해요.
앞의 useTransition에서 나온 "응답 순서가 뒤바뀌는 문제"가 useActionState에서는 일어나지 않는 이유가 이거예요.
다만 요청이 오래 걸리면 화면이 느리게 반응하는 것처럼 보일 수 있어요. 이럴 때 useOptimistic을 함께 쓰면 돼요. useOptimistic은 Action이 진행되는 동안 임시로 보여줄 값을 만들어 줘요. 서버 응답을 기다리지 않고 화면에 결과를 먼저 보여주는 거예요.
import { useActionState, useOptimistic, startTransition } from 'react';
function Checkout() {
// updateCartAction: 서버에 요청하는 reducerAction
const [count, dispatchAction, isPending] = useActionState(updateCartAction, 0);
const [optimisticCount, setOptimisticCount] = useOptimistic(count);
function handleAdd() {
startTransition(() => {
// 화면에는 즉시 +1 반영
setOptimisticCount(c => c + 1);
// 실제 요청은 순서대로 처리
dispatchAction({ type: 'ADD' });
});
}
// ...
}
useOptimistic(count)는 [optimisticCount, setOptimisticCount]를 돌려줘요. Action이 진행 중이 아닐 때 optimisticCount는 count와 같아요.setOptimisticCount는 Action 안에서 호출해야 해요. 위 코드는 startTransition 안에서 호출하고 있어요.count)이 화면에 반영돼요.catch해서 사용자에게 메시지를 보여줄 수 있어요.순서대로 실행되는 것 자체를 피하고 싶다면, 대기 중인 Action을 AbortController로 취소하는 방법도 있어요. 다만 서버에 이미 반영된 변경(예를 들어 DB에 쓰기)은 요청을 취소해도 되돌릴 수 없어서, 취소해도 안전한 경우에만 쓸 수 있어요. 더 복잡한 경우에는 useActionState를 쓰지 않는 방법도 함께 고려하라고 공식 문서는 안내해요.
dispatchAction을 <form>의 action에 넘기면, React가 자동으로 Transition으로 감싸 주기 때문에 startTransition을 직접 쓸 필요가 없어요. 이때 reducerAction은 이전 state와 제출된 FormData를 받아요.
function Signup() {
const [state, dispatchAction, isPending] = useActionState(signupAction, { error: null });
return (
<form action={dispatchAction}>
<input name="email" />
<button disabled={isPending}>가입</button>
{state.error && <p>{state.error}</p>}
</form>
);
}
// 첫 번째 인자: 이전 state, 두 번째 인자: 제출된 FormData
async function signupAction(prevState, formData) {
const res = await fetch('/api/signup', { method: 'POST', body: formData });
if (!res.ok) {
return { error: '가입에 실패했어요' };
}
return { error: null };
}
useActionState를 쓰지 않을 때의 form action은 function action(formData)처럼 인자가 formData 하나인데, useActionState를 쓰면 첫 번째 인자가 이전 state로 밀려서 formData는 두 번째 인자가 돼요. 헷갈리기 쉬운 부분이에요.
에러는 두 가지로 나눠서 다뤄요.
reducerAction이 반환하는 state에 담아서 화면에 보여줘요.throw하면 React가 대기 중인 Action을 전부 취소하고, 가장 가까운 Error Boundary를 보여줘요.startTransition 없이 dispatchAction을 직접 호출: An async function with useActionState was called outside of a transition 에러가 나고, isPending도 제대로 갱신되지 않아요. <form action>이나 Action을 받는 prop에 넘길 때는 자동으로 감싸지니 괜찮아요.reducerAction의 첫 번째 인자를 formData로 착각: 첫 번째는 이전 state예요.dispatchAction 호출: Cannot update action state while rendering 에러가 나고, 무한 루프가 돼요. 클릭이나 폼 제출 같은 사용자 이벤트에서만 호출하세요.reducerAction에서 에러를 throw함: 그 뒤에 줄 서 있던 호출이 전부 건너뛰어져요. 예상 가능한 에러는 try/catch로 잡아서 에러 state를 반환하세요.| 이런 상황이라면 | 이 Hook을 고려해요 |
|---|---|
| state를 바꾸는 경우가 많아서 코드가 흩어져요 | useReducer |
| 느린 계산 결과를 재사용하고 싶어요 | useMemo |
memo로 감싼 자식에게 넘기는 함수를 유지하고 싶어요 | useCallback |
| 급하지 않은 state 업데이트를 미루고, 진행 상태를 보여주고 싶어요 | useTransition |
| 입력이 느린 화면 영역 때문에 버벅여요 | useDeferredValue |
| 서버 요청 결과를 state로 관리하고, 요청 순서를 보장하고 싶어요 | useActionState |
지금까지 배운 Hook만으로도 대부분의 화면을 만들 수 있어요. 이 섹션의 Hook 일곱 개는 특정 상황에서만 필요해요. 공식 문서도 일부는 라이브러리를 만드는 사람을 위한 Hook이라고 안내해요. 그러니 지금 다 외울 필요는 없고, "이런 상황에 쓰는 Hook이 있구나" 정도만 기억해 두세요. 나중에 그 상황을 만났을 때 다시 찾아보면 돼요.
| Hook | 이럴 때 떠올려요 |
|---|---|
useId | 접근성 속성 등에 쓸 고유한 ID가 필요할 때 |
useEffectEvent | Effect 안에서 최신 값을 읽고 싶지만, 그 값이 바뀔 때 Effect가 다시 실행되는 건 싫을 때 |
useImperativeHandle | 부모에게 "포커스 주기" 같은 일부 동작만 공개하고 싶을 때 |
useSyncExternalStore | React 밖에서 바뀌는 데이터(브라우저 API, 외부 상태 라이브러리)를 구독할 때 |
useLayoutEffect | 화면이 그려지기 전에 요소의 크기나 위치를 측정해서 다시 렌더링해야 할 때 |
useInsertionEffect | CSS-in-JS 라이브러리를 만들면서 <style> 태그를 넣어야 할 때 |
useDebugValue | 커스텀 Hook에 개발자 도구용 라벨을 달고 싶을 때 |
useId — 고유한 ID 만들기접근성 속성에 쓸 고유한 ID 문자열을 만들어 주는 Hook이에요.
접근성 속성 중 aria-describedby는 "이 요소는 저 요소로 설명된다"는 관계를 스크린 리더 같은 보조 기술에 알려줘요. 예를 들어 비밀번호 입력창과 그 아래의 안내 문구를 연결할 때 써요. 이때 두 요소를 이어 주는 것이 id예요.
<label>
비밀번호:
<input type="password" aria-describedby="password-hint" />
</label>
<p id="password-hint">비밀번호는 18자 이상이어야 해요</p>
React에서는 하나의 컴포넌트가 화면에 여러 번 나타날 수 있어요. 그런데 id는 페이지 안에서 유일해야 해요. "password-hint"처럼 직접 적어 두면, 이 컴포넌트를 두 번 쓰는 순간 같은 id가 두 개 생겨요.
import { useId } from 'react';
function PasswordField() {
// 컴포넌트마다 고유한 ID 생성
const passwordHintId = useId();
return (
<>
<label>
비밀번호:
<input type="password" aria-describedby={passwordHintId} />
</label>
<p id={passwordHintId}>
비밀번호는 18자 이상이어야 해요
</p>
</>
);
}
export default function App() {
return (
<>
<h2>비밀번호 입력</h2>
<PasswordField />
<h2>비밀번호 확인</h2>
<PasswordField />
</>
);
}
PasswordField가 두 번 나타나도 각각 다른 ID가 만들어져서 충돌하지 않아요.
nextId++ 같은 직접 만든 카운터 대신 useId를 쓰는 이유는 서버 렌더링과 함께 쓸 수 있기 때문이에요. 서버에서 만든 HTML의 ID와 브라우저에서 만든 ID가 같아야 하는데, 직접 만든 카운터로는 이걸 보장하기 어려워요.
요소마다 useId를 호출하지 않고, 하나를 만들어 뒤에 이름을 붙여 쓸 수 있어요.
function Form() {
const id = useId();
return (
<form>
<label htmlFor={id + '-firstName'}>이름:</label>
<input id={id + '-firstName'} type="text" />
<hr />
<label htmlFor={id + '-lastName'}>성:</label>
<input id={id + '-lastName'} type="text" />
</form>
);
}
key로 쓰면 안 돼요. key는 데이터에서 만들어야 해요.useEffectEvent — Effect 안에서 최신 값만 읽기Effect 안에서 최신 props나 state를 읽으면서도, 그 값이 바뀔 때 Effect가 다시 실행되지 않게 해주는 Hook이에요.
2-2에서 배운 것처럼, Effect 안에서 쓰는 props와 state는 전부 의존성 배열에 넣어야 해요. 그런데 "읽기만 하고 싶을 뿐, 그 값이 바뀐다고 해서 Effect를 다시 실행하고 싶지는 않은" 값이 있을 때 문제가 생겨요.
채팅방에 연결되면 알림을 띄우는 컴포넌트를 생각해 볼게요. 사용자가 "알림 끄기"(muted)를 켜 두면 알림을 띄우지 않아요.
function ChatRoom({ roomId, muted }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
if (!muted) {
showNotification('채팅방에 연결되었어요');
}
});
connection.connect();
return () => connection.disconnect();
}, [roomId, muted]); // muted 변경 시에도 채팅방 재연결
}
muted를 Effect 안에서 쓰니까 의존성에 넣어야 하고, 그러면 알림 설정을 바꿀 때마다 채팅방 연결이 끊겼다가 다시 연결돼요. 원하는 동작이 아니에요.
useEffectEvent로 해결import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, muted }) {
// Effect Event: 호출 시점의 최신 muted 값 읽기
const onConnected = useEffectEvent(() => {
if (!muted) {
showNotification('채팅방에 연결되었어요');
}
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // onConnected와 muted는 의존성에서 제외
}
useEffectEvent로 만든 함수(Effect Event)는 Effect의 일부이지만 이벤트 핸들러처럼 동작해요. 호출할 때마다 그 시점의 최신 값을 읽고, 의존성 배열에는 넣지 않아요. 이제 알림 설정을 바꿔도 채팅방은 다시 연결되지 않고, roomId가 바뀔 때만 다시 연결돼요.
eslint-plugin-react-hooks가 이 규칙을 검사해 줘요.// 잘못된 방식: 의존성을 숨기기 위한 사용
const logVisit = useEffectEvent(() => {
log(pageUrl);
});
useEffect(() => {
logVisit();
}, []); // pageUrl이 바뀌어도 로그 누락
값이 바뀔 때 Effect가 다시 실행되어야 한다면 그 값은 의존성으로 남겨 두세요. useEffectEvent는 정말로 "Effect 안에서 발생하는 이벤트"인 로직에만 써요.
useImperativeHandle — 부모에게 공개할 동작 고르기부모가 ref로 접근했을 때 무엇을 받을지 직접 정하는 Hook이에요.
2-3에서 ref를 prop으로 받아 안쪽 <input>에 그대로 넘기면, 부모는 그 DOM 요소 전체에 접근할 수 있다고 배웠어요. 그런데 DOM 요소 전체를 열어 주는 대신 focus와 scrollIntoView 같은 일부 동작만 공개하고 싶을 때가 있어요.
import { useRef, useImperativeHandle } from 'react';
function MyInput({ ref }) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => {
// 부모에게 공개할 동작만 담은 객체
return {
focus() {
inputRef.current.focus();
},
scrollIntoView() {
inputRef.current.scrollIntoView();
},
};
}, []);
// 실제 DOM 요소는 내부 ref에만 연결
return <input ref={inputRef} />;
}
function Form() {
const ref = useRef(null);
function handleClick() {
ref.current.focus();
// 동작하지 않는 코드: DOM 요소 전체가 공개되지 않음
// ref.current.style.opacity = 0.5;
}
return (
<form>
<MyInput placeholder="이름을 입력하세요" ref={ref} />
<button type="button" onClick={handleClick}>
수정
</button>
</form>
);
}
useImperativeHandle(ref, createHandle, dependencies?)의 두 번째 인자인 createHandle이 돌려주는 객체가 부모의 ref.current가 돼요. 부모는 focus()와 scrollIntoView()는 호출할 수 있지만, <input> DOM 요소에는 직접 접근할 수 없어요.
공식 문서는 ref를 props로는 표현할 수 없는 명령형 동작에만 쓰라고 해요. 예를 들면 스크롤하기, 포커스 주기, 애니메이션 시작하기, 텍스트 선택하기 같은 것들이에요.
props로 표현할 수 있다면 ref를 쓰지 않는 게 좋아요. 예를 들어 Modal 컴포넌트에서 { open, close } 메서드를 공개하는 것보다, isOpen을 prop으로 받는 <Modal isOpen={isOpen} />가 더 좋아요.
useSyncExternalStore — React 밖의 데이터 구독하기React 밖에서 바뀌는 데이터를 구독해서, 그 데이터가 바뀌면 컴포넌트를 다시 렌더링하는 Hook이에요.
대부분의 컴포넌트는 props, state, context만 읽어요. 그런데 가끔 React와 상관없이 값이 바뀌는 곳에서 데이터를 읽어야 할 때가 있어요.
이런 값은 React가 변화를 알 수 없기 때문에, 직접 "이 값이 바뀌면 알려줘"라고 구독해야 해요.
브라우저의 navigator.onLine은 현재 네트워크에 연결되어 있는지를 알려줘요. 이 값은 React와 상관없이 바뀌어요.
import { useSyncExternalStore } from 'react';
// 변경 시 callback을 호출하고, 구독 해제 함수를 반환
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribe, // 구독하는 방법
() => navigator.onLine, // 클라이언트에서 값을 읽는 방법
() => true // 서버에서 값을 읽는 방법
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? '온라인' : '연결 끊김'}</h1>;
}
useSyncExternalStore에는 함수 세 개를 넘겨요.
subscribe: 스토어를 구독하는 함수예요. 스토어가 바뀌면 받은 callback을 호출하고, 구독을 해제하는 함수를 반환해요.getSnapshot: 스토어의 현재 값을 읽는 함수예요. 스토어가 바뀌지 않았다면 여러 번 호출해도 같은 값을 돌려줘야 해요.getServerSnapshot (선택): 서버에서 HTML을 만들 때, 그리고 그 HTML에 React가 이벤트를 연결하는 동안 쓸 초기값이에요. 서버에는 navigator 같은 브라우저 API가 없어서 필요해요.보통은 위처럼 useOnlineStatus 같은 커스텀 Hook으로 감싸서 써요. 그러면 여러 컴포넌트가 같은 코드를 반복하지 않고 같은 스토어를 쓸 수 있어요.
useState와 useReducer를 쓰세요. 이 Hook은 주로 React가 아닌 기존 코드와 연결할 때 유용해요.useEffect와 useState로 구독을 직접 구현하는 방식보다 이 Hook을 권해요. 덜 실수하기 쉬워요.getSnapshot이 호출할 때마다 새 객체를 돌려줌: React가 "값이 바뀌었다"고 판단해서 무한 반복에 빠져요. The result of getSnapshot should be cached 에러가 나요. 스토어에 실제로 바뀐 것이 있을 때만 새 값을 돌려주세요.subscribe 함수를 컴포넌트 안에서 정의함: 렌더링마다 다른 함수가 되어서 React가 매번 다시 구독해요. subscribe를 컴포넌트 밖으로 옮기세요.useLayoutEffect — 화면이 그려지기 전에 실행하기useEffect와 똑같이 쓰지만, 브라우저가 화면을 다시 그리기 전에 실행되는 Hook이에요.
주의:
useLayoutEffect는 성능을 해칠 수 있어요. 가능하면useEffect를 쓰세요. 공식 문서도 대부분의 Effect에는 필요 없다고 안내해요.
마우스를 올리면 나타나는 툴팁을 생각해 볼게요. 요소 위쪽에 공간이 충분하면 위에, 아니면 아래에 보여주고 싶어요. 그러려면 툴팁의 높이를 알아야 해요. 그런데 높이는 일단 화면에 넣어 봐야 알 수 있어요.
0으로 가정).이 모든 일이 브라우저가 화면을 그리기 전에 끝나야 해요. 그렇지 않으면 사용자가 툴팁이 잘못된 위치에 나타났다가 옮겨지는 모습을 보게 돼요.
import { useState, useRef, useLayoutEffect } from 'react';
function Tooltip() {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0); // 실제 높이를 아직 모름
useLayoutEffect(() => {
// 화면이 그려지기 전 툴팁 높이 측정
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height); // 실제 높이를 알았으니 즉시 다시 렌더링
}, []);
// ...tooltipHeight를 사용해 위치 결정...
}
useLayoutEffect 안의 코드와 그 안에서 예약한 state 업데이트는 브라우저가 화면을 그리기 전에 모두 처리돼요. 그래서 사용자는 첫 번째(잘못된 위치) 렌더링을 보지 못하고 최종 결과만 봐요. useEffect를 쓰면 React가 화면을 먼저 그리게 둘 수 있어서, 잘못된 위치가 잠깐 보였다가 바뀌는 깜빡임이 생길 수 있어요.
useLayoutEffect의 코드와 그 안의 state 업데이트는 브라우저가 화면을 그리는 것을 막아요. 많이 쓰면 앱이 느려져요.useLayoutEffect does nothing on the server 경고가 보이면, useEffect로 바꾸는 것이 가장 간단한 해결책이에요.useInsertionEffect — CSS-in-JS 라이브러리용다른 Effect들이 실행되기 전에 DOM에 요소를 넣을 수 있게 해주는 Hook이에요.
공식 문서의 안내:
useInsertionEffect는 CSS-in-JS 라이브러리를 만드는 사람을 위한 Hook이에요. 그런 라이브러리를 만들면서 스타일을 넣을 자리가 필요한 경우가 아니라면,useEffect나useLayoutEffect를 쓰세요.
CSS-in-JS는 CSS 파일 대신 자바스크립트 코드 안에서 스타일을 작성하는 방식이에요. 일부 라이브러리는 실행 중에 <style> 태그를 만들어 페이지에 넣어요. 이때 스타일이 너무 늦게 들어가면 문제가 생겨요. 다른 Effect에서 요소의 크기를 측정하는데, 그 시점에 스타일이 아직 없으면 측정값이 틀리기 때문이에요.
useInsertionEffect는 다른 Effect들보다 먼저 실행되므로, 그 전에 스타일을 넣어 둘 수 있어요.
import { useInsertionEffect } from 'react';
// CSS-in-JS 라이브러리 내부
function useCSS(rule) {
useInsertionEffect(() => {
// <style> 태그 삽입
});
return rule;
}
ref가 아직 연결되지 않았어요.useEffect와 같아요.)애플리케이션을 만드는 입장에서는 쓸 일이 거의 없어요. 이름만 알아 두세요.
useDebugValue — 개발자 도구에 라벨 달기커스텀 Hook에 React 개발자 도구(React DevTools)에서 보이는 라벨을 달아주는 Hook이에요.
import { useDebugValue } from 'react';
function useOnlineStatus() {
// ...
useDebugValue(isOnline ? 'Online' : 'Offline');
// ...
}
useOnlineStatus를 쓰는 컴포넌트를 개발자 도구로 검사하면 OnlineStatus: "Online"이라는 라벨이 보여요. useDebugValue가 없으면 안쪽의 값인 true만 보여서, 그게 무슨 뜻인지 알아보기 어려워요.
두 번째 인자로 포맷 함수를 넘기면, 디버그 값을 검사할 때만 포맷 작업을 실행해요.
// 개발자 도구에서 검사할 때만 toDateString 실행
useDebugValue(date, date => date.toDateString());
렌더링마다 toDateString()을 실행하지 않아도 돼서, 비용이 드는 포맷 작업을 피할 수 있어요.
모든 커스텀 Hook에 붙일 필요는 없어요. 공식 문서는 공유 라이브러리에 들어가는, 내부 데이터 구조가 복잡해서 들여다보기 어려운 커스텀 Hook에 가장 유용하다고 해요.
| 이런 상황이라면 | 이 Hook |
|---|---|
| 화면에 보여줄 값을 기억하고 싶어요 | useState |
| state를 바꾸는 경우가 많아서 코드가 흩어져요 | useReducer |
| 타이머나 서버 연결 같은 React 밖의 것과 맞춰야 해요 | useEffect |
| 화면과 상관없는 값(타이머 ID 등)을 기억하거나 DOM 요소를 다뤄야 해요 | useRef |
| 깊이 있는 컴포넌트에 props 없이 값을 전달하고 싶어요 | useContext |
| 느린 계산 결과를 재사용하고 싶어요 | useMemo |
memo로 감싼 자식에게 넘기는 함수를 유지하고 싶어요 | useCallback |
| 급하지 않은 업데이트를 미루고 진행 상태를 보여주고 싶어요 | useTransition |
| 느린 화면 영역 때문에 입력이 버벅여요 | useDeferredValue |
| 서버 요청 결과를 state로 관리하고 요청 순서를 보장하고 싶어요 | useActionState |
| 접근성 속성에 쓸 고유한 ID가 필요해요 | useId |
| Effect 안에서 최신 값을 읽되 Effect를 다시 실행시키고 싶지 않아요 | useEffectEvent |
| 부모에게 일부 동작만 공개하고 싶어요 | useImperativeHandle |
| React 밖에서 바뀌는 데이터를 구독해야 해요 | useSyncExternalStore |
| 화면이 그려지기 전에 크기나 위치를 측정해야 해요 | useLayoutEffect |
| CSS-in-JS 라이브러리를 만들어요 | useInsertionEffect |
| 커스텀 Hook에 개발자 도구용 라벨을 달고 싶어요 | useDebugValue |
Hook을 많이 알수록 좋은 게 아니라, 언제 쓰지 않아도 되는지 아는 것이 더 중요해요. 이 글 전체를 세 문장으로 줄이면 이래요.
useMemo, useCallback, memo는 느려졌을 때 측정하고 나서 붙이는 도구예요. (3-2)useReducer를 더 자세히 다뤄요.이 글에서는 3-6에서 잠깐만 다룬 useOptimistic도 공식 문서에서 더 살펴보세요. useActionState, useTransition과 함께 쓰는 경우가 많아요.