훅과 상태 관리
useState, useEffect, useContext, useRef와 직접 만든 훅에 타입을 붙이고, Effect의 정리 함수가 언제 실행되는지와 늦게 도착한 결과를 다루는 방법을 확인한 뒤, 값의 성격에 따라 상태를 어디에 둘지 정하는 기준을 정리합니다.
훅(Hook)은 React 16.8에 들어온 기능으로, 함수 컴포넌트가 상태를 갖고 렌더링 바깥의 시스템과 동기화할 수 있게 해 줍니다. 예전에는 클래스 컴포넌트에서만 하던 일을 함수 안에서 하게 되면서, 지금은 대부분의 React 코드가 함수 컴포넌트와 훅으로 쓰입니다.
훅은 제네릭 함수여서, 초기값이나 타입 인수로 다루는 값의 타입이 정해집니다. 이 글은 useState, useEffect, useContext, useRef와 직접 만든 훅의 타입을 차례로 보고, 마지막에 값을 어느 훅에 둘지 정하는 기준을 정리합니다. 예제는 앞 글처럼 Vite의 react-ts 템플릿에서 컴포넌트마다 .tsx 파일 하나로 만들고, JSX가 없는 훅만 .ts 파일로 둡니다.
useState로 여러 상태 다루기
앞 글에서 본 것처럼 useState는 초기값으로 타입을 추론하고, null로 시작하는 상태에는 타입 인수를 적습니다. 다음 UserProfile은 사용자를 불러오는 동안의 세 가지 상태를 각각 따로 둡니다.
import { useState } from "react";
interface User {
id: number;
name: string;
email: string;
}
function fakeFetchUser(userId: number): Promise<User | null> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(userId === 1 ? { id: 1, name: "Alice", email: "[email protected]" } : null);
}, 1000);
});
}
export default function UserProfile() {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchUserProfile = async (userId: number) => {
setIsLoading(true);
setError(null);
try {
const found = await fakeFetchUser(userId);
if (found) {
setUser(found);
} else {
setUser(null);
setError("User not found!");
}
} catch (err) {
setUser(null);
setError(err instanceof Error ? err.message : "Failed to fetch user.");
} finally {
setIsLoading(false);
}
};
return (
<div>
<button onClick={() => fetchUserProfile(1)} disabled={isLoading}>
{isLoading ? "Loading..." : "Fetch User 1"}
</button>
<button onClick={() => fetchUserProfile(99)} disabled={isLoading}>
{isLoading ? "Loading..." : "Fetch User 99"}
</button>
{error && <p>Error: {error}</p>}
{user ? <p>{user.name} ({user.email})</p> : !isLoading && !error && <p>Please fetch a user.</p>}
</div>
);
}catch의 err는 strict에서 unknown이므로, instanceof Error로 좁힌 뒤에 message를 읽습니다. 처음에는 Please fetch a user.가 보이고, Fetch User 1을 누르면 1초 동안 두 버튼이 Loading...으로 바뀐 뒤 Alice ([email protected])가 보입니다. fakeFetchUser는 서버에 요청하지 않고 타이머로 결과를 흉내 내는 함수입니다.
그 상태에서 Fetch User 99를 누르면, 불러오는 1초 동안에는 이전의 user가 그대로 남아 있어 Alice가 계속 보입니다. 1초 뒤에는 user가 null이 되고 Error: User not found!가 보입니다. 세 상태를 따로 두면 이렇게 불러오는 중이면서 이전 사용자가 보이는 조합이 생기며, 어떤 조합이 가능한지 타입만으로는 알 수 없습니다. 이 문제를 하나의 유니온 타입으로 푸는 방법은 다음 글에서 다룹니다.
useEffect와 정리 함수
useEffect는 화면에 반영된 컴포넌트를 네트워크, 타이머, 구독처럼 React 바깥의 시스템과 동기화할 때 씁니다. 첫 번째 인수인 설정 함수(setup)가 바깥의 작업을 시작하고, 설정 함수가 돌려주는 정리 함수(cleanup)가 그 작업을 해제합니다. 타입으로는 설정 함수가 아무것도 돌려주지 않거나 정리 함수를 돌려주어야 하므로, async 함수를 바로 넘겨 Promise를 돌려주면 진단됩니다.
Effect는 setup·cleanup과 현재 요청 판정으로 외부 시스템을 동기화한다 — 상태 추적
effect setup/cleanup과 비동기 응답의 현재/오래된 소유권이 달라진다. 본문 cleanup은 로그만 있으므로 취소·낡은 결과 차단은 설계 보강임을 표시해야 한다.
EFFECT / SYNCHRONIZATION / RESULT OWNERSHIP
Effect의 setup·cleanup과 비동기 결과 반영을 구분한다
커밋 뒤 setup이 외부 작업을 시작합니다. 의존성 변경 때 이전 cleanup 뒤 새 setup을 실행하고, unmount 때 cleanup합니다. 아래 그림의 현재 요청 판정은 본문 로그 cleanup에 추가하는 설계입니다.
SETUP
외부 시스템과 동기화
구독·연결·요청 등 외부 작업이 필요할 때 Effect를 사용합니다. 렌더에서 계산할 수 있는 파생값을 별도 Effect로 동기화할 필요는 없습니다.
CLEANUP TIMING
결과 도착과 별개의 수명
의존성이 바뀌면 이전 값의 cleanup 뒤 새 값의 setup을 실행합니다. unmount 때 마지막 cleanup을 실행하며, 요청 성공·실패 자체가 cleanup을 실행시키는 것은 아닙니다.
ASYNC OWNERSHIP
상태 반영 전에 현재성 검사
ignore flag나 요청 identity로 이전 성공·실패가 최신 상태를 덮어쓰지 않게 합니다. 취소 API를 지원하는 자원은 해제·취소하고, 취소가 모든 이후 처리를 자동 차단한다고 가정하지 않습니다.
LESSON LIMIT
교재 cleanup은 로그만 출력
DataFetcher의 1.5초 timer는 cleanup에서 clearTimeout하지 않으며 오래된 결과도 차단하지 않습니다. 그림의 현재성 검사는 설명 확장이고 구현되어 있다고 주장하지 않습니다.
DEVELOPMENT CHECK
빈 의존성 배열과 Strict Mode
[]도 개발 Strict Mode의 추가 setup → cleanup → setup 검사를 막지 않습니다. cleanup이 setup의 외부 작업을 해제하는지 확인합니다. Effect는 서버 렌더에서 실행되지 않습니다.
외부 작업의 수명 관리와 결과의 현재성은 두 계약입니다. 실제 예제의 cleanup 구현을 확인하고 필요한 자원 해제와 결과 무시를 추가합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| setupA | 현재 소유자: A / UI: 요청 중 | 현재 입력의 Effect가 요청 A를 시작한다 | cleanup 로그 예제를 요청 안전장치 구현으로 오인하지 않는다 |
| 입력 B commit | 이전 A: 권한 종료 모델 / 현재: B | 이전 cleanup 뒤 새 setup B를 시작하는 보강 모델이다 | cancel/stale gate는 본문 코드에 추가할 설계 가정이다 |
| 현재 결과 채택 | B: 현재 UI 반영 / A: 무시 모델 | B 결과만 반영하고 늦은 A는 무시하는 확장 계약이다 | 실제 source의 로그만으로 이 안전성이 완성되었다고 하지 않는다 |
Effect는 setup·cleanup과 현재 요청 판정으로 외부 시스템을 동기화한다 — 상태 추적
effect setup/cleanup과 비동기 응답의 현재/오래된 소유권이 달라진다. 본문 cleanup은 로그만 있으므로 취소·낡은 결과 차단은 설계 보강임을 표시해야 한다.
EFFECT / SYNCHRONIZATION / RESULT OWNERSHIP
Effect의 setup·cleanup과 비동기 결과 반영을 구분한다
커밋 뒤 setup이 외부 작업을 시작합니다. 의존성 변경 때 이전 cleanup 뒤 새 setup을 실행하고, unmount 때 cleanup합니다. 아래 그림의 현재 요청 판정은 본문 로그 cleanup에 추가하는 설계입니다.
SETUP
외부 시스템과 동기화
구독·연결·요청 등 외부 작업이 필요할 때 Effect를 사용합니다. 렌더에서 계산할 수 있는 파생값을 별도 Effect로 동기화할 필요는 없습니다.
CLEANUP TIMING
결과 도착과 별개의 수명
의존성이 바뀌면 이전 값의 cleanup 뒤 새 값의 setup을 실행합니다. unmount 때 마지막 cleanup을 실행하며, 요청 성공·실패 자체가 cleanup을 실행시키는 것은 아닙니다.
ASYNC OWNERSHIP
상태 반영 전에 현재성 검사
ignore flag나 요청 identity로 이전 성공·실패가 최신 상태를 덮어쓰지 않게 합니다. 취소 API를 지원하는 자원은 해제·취소하고, 취소가 모든 이후 처리를 자동 차단한다고 가정하지 않습니다.
LESSON LIMIT
교재 cleanup은 로그만 출력
DataFetcher의 1.5초 timer는 cleanup에서 clearTimeout하지 않으며 오래된 결과도 차단하지 않습니다. 그림의 현재성 검사는 설명 확장이고 구현되어 있다고 주장하지 않습니다.
DEVELOPMENT CHECK
빈 의존성 배열과 Strict Mode
[]도 개발 Strict Mode의 추가 setup → cleanup → setup 검사를 막지 않습니다. cleanup이 setup의 외부 작업을 해제하는지 확인합니다. Effect는 서버 렌더에서 실행되지 않습니다.
외부 작업의 수명 관리와 결과의 현재성은 두 계약입니다. 실제 예제의 cleanup 구현을 확인하고 필요한 자원 해제와 결과 무시를 추가합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| setupA | 현재 소유자: A / UI: 요청 중 | 현재 입력의 Effect가 요청 A를 시작한다 | cleanup 로그 예제를 요청 안전장치 구현으로 오인하지 않는다 |
| 입력 B commit | 이전 A: 권한 종료 모델 / 현재: B | 이전 cleanup 뒤 새 setup B를 시작하는 보강 모델이다 | cancel/stale gate는 본문 코드에 추가할 설계 가정이다 |
| 현재 결과 채택 | B: 현재 UI 반영 / A: 무시 모델 | B 결과만 반영하고 늦은 A는 무시하는 확장 계약이다 | 실제 source의 로그만으로 이 안전성이 완성되었다고 하지 않는다 |
그림은 정리 함수가 실행되는 때와, 늦게 도착한 결과를 반영할지 판단하는 과정을 함께 보여 줍니다. 의존성 배열의 값이 바뀌면 React는 이전 값의 정리 함수를 실행한 뒤 새 값으로 설정 함수를 실행하고, 컴포넌트가 사라질 때 마지막 정리 함수를 실행합니다. 요청이 성공하거나 실패했다고 정리 함수가 실행되는 것은 아닙니다. 먼저 정리 함수가 로그만 남기는 첫 번째 모양을 봅니다.
import { useEffect, useState } from "react";
export default function DataFetcher() {
const [data, setData] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await new Promise<string>((resolve) => {
setTimeout(() => resolve("Fetched data successfully!"), 1500);
});
setData(response);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch data.");
} finally {
setLoading(false);
}
};
fetchData();
return () => {
console.log("Cleanup for DataFetcher");
};
}, []);
if (loading) return <p>Loading data...</p>;
if (error !== null) return <p>Error: {error}</p>;
return <p>{data}</p>;
}설정 함수 안에 async 함수를 만들어 부르는 것은, 설정 함수 자체가 Promise를 돌려주지 않게 하기 위해서입니다. 화면은 Loading data...를 1.5초 보여 준 뒤 Fetched data successfully!로 바뀝니다. 하지만 정리 함수가 로그만 남기므로, 1.5초가 지나기 전에 컴포넌트가 사라져도 타이머는 그대로 남아 콜백을 실행합니다.
빈 의존성 배열 []은 마운트할 때 한 번 설정하고 사라질 때 정리한다는 뜻이지만, 개발 모드의 StrictMode는 정리 함수가 제대로 짝을 이루는지 확인하려고 마운트 직후에 설정, 정리, 설정을 한 번 더 실행합니다. 그래서 개발 모드에서는 이 컴포넌트가 타이머를 두 개 만들고, 콘솔에 Cleanup for DataFetcher가 한 번 찍힙니다. 정리 함수는 설정 함수가 시작한 작업을 실제로 해제해야 하며, 다음 컴포넌트는 타이머를 취소합니다.
import { useEffect, useState } from "react";
export default function DataFetcherSafe({ shouldFail = false }: { shouldFail?: boolean }) {
const [data, setData] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setData(null);
setError(null);
const timer = setTimeout(() => {
if (shouldFail) {
setError("연습 실패");
} else {
setData("연습 성공");
}
setLoading(false);
}, 500);
return () => clearTimeout(timer);
}, [shouldFail]);
if (loading) return <p>Loading data...</p>;
if (error !== null) return <p>Error: {error}</p>;
return <p>{data}</p>;
}shouldFail이 false이면 0.5초 뒤 연습 성공이, true이면 Error: 연습 실패가 보입니다. 0.5초가 지나기 전에 부모가 shouldFail을 바꾸면, React는 이전 정리 함수로 첫 타이머를 취소한 뒤 새 타이머를 예약하므로 이전 값의 결과가 화면에 나타나지 않습니다. 개발 모드의 설정, 정리, 설정에서도 첫 타이머는 취소되고 마지막 타이머 하나만 남습니다.
이 컴포넌트가 취소하는 것은 아직 실행되지 않은 타이머입니다. 실제 네트워크 요청은 취소할 수 없거나 이미 응답이 오는 중일 수 있으므로, useEffect 공식 문서는 설정 함수에서 let ignore = false;를 두고 정리 함수에서 ignore = true;로 바꾼 뒤, 응답을 반영하기 전에 그 값을 확인하는 방법을 보여 줍니다. 그림의 현재 요청 판정이 이 방법에 해당하며, 이 글의 두 예제는 그 판정까지 구현한 것은 아닙니다.
useContext로 값 나누기
Context는 props를 여러 단계에 걸쳐 넘기지 않고, 하위 트리의 컴포넌트들에게 값을 전달하는 방법입니다. createContext의 기본값이 Context의 타입을 정하므로, Provider 밖에서 쓰면 안 되는 Context는 기본값을 undefined로 두고 타입에 그것을 포함합니다.
import { createContext, useContext, useState } from "react";
import type { ReactNode } from "react";
type Theme = "light" | "dark";
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
const toggleTheme = () => setTheme((prev) => (prev === "light" ? "dark" : "light"));
return <ThemeContext value={{ theme, toggleTheme }}>{children}</ThemeContext>;
}
function useTheme(): ThemeContextValue {
const value = useContext(ThemeContext);
if (value === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return value;
}
function ThemeToggleButton() {
const { theme, toggleTheme } = useTheme();
return <button onClick={toggleTheme}>Toggle Theme ({theme})</button>;
}
export default function ThemeApp() {
return (
<ThemeProvider>
<ThemeToggleButton />
<ThemeToggleButton />
</ThemeProvider>
);
}useTheme은 undefined를 검사해 Provider 밖에서 쓴 경우를 의미 있는 오류로 바꾸고, 그 뒤로는 ThemeContextValue를 돌려줍니다. 그래서 Context를 쓰는 컴포넌트마다 undefined를 검사할 필요가 없습니다. useState<Theme>에 타입 인수를 적은 것은, 초기값 "light"만 넘기면 컴파일러가 상태의 타입을 "light"가 아니라 string으로 넓혀 추론하기 때문입니다. 그러면 theme에 아무 문자열이나 들어갈 수 있게 되고, Theme을 요구하는 Context의 값에 theme을 넘기는 줄이 진단됩니다.
React 19부터는 <ThemeContext value={...}>처럼 Context 자체를 Provider로 쓸 수 있으며, 예전의 <ThemeContext.Provider value={...}>도 동작합니다. 두 버튼은 같은 Provider 아래에 있으므로 같은 theme를 읽고, 어느 버튼을 누르든 Provider의 상태가 바뀌어 두 버튼 모두 Toggle Theme (dark)로 바뀝니다. Context 자체가 값을 저장하는 것은 아니며, 이 예제에서 값을 가진 것은 ThemeProvider의 상태입니다.
더 깊이: Context의 값이 바뀌었다고 판단하는 기준
useContext 공식 문서에 따르면, Provider가 받은 value가 이전과 달라지면 React는 그 아래에서 그 Context를 쓰는 모든 컴포넌트를 다시 렌더링합니다. 이전 값과 새 값은 Object.is로 비교하고, 중간의 컴포넌트를 memo로 감싸 렌더링을 건너뛰게 해도 Context를 읽는 컴포넌트는 새 값을 받아 다시 렌더링됩니다. 그런데 이 글의 ThemeProvider처럼 value={{ theme, toggleTheme }}로 객체를 그 자리에서 만들면, Provider가 다시 렌더링될 때마다 내용이 같아도 새 객체가 되어 비교에서 언제나 다르다고 판단됩니다.
문서는 작은 앱에서는 이것이 문제가 되지 않는다고 하면서, 필요하다면 함수를 useCallback으로, 값 객체를 useMemo로 감싸 실제 데이터가 바뀔 때만 새 객체가 되게 하라고 안내합니다. 그러면 Provider가 다른 이유로 다시 렌더링되어도 Context를 쓰는 컴포넌트는 다시 렌더링되지 않습니다. 같은 문서는 Context를 제공하는 쪽과 읽는 쪽이 ===로 같은 Context 객체여야 한다는 점도 짚는데, 심벌릭 링크 같은 이유로 빌드 결과에 같은 모듈이 두 벌 들어가면 Context가 이어지지 않을 수 있습니다.
useRef로 DOM 요소 참조하기
useRef는 렌더링 사이에 유지되지만 바꾸어도 다시 렌더링을 요청하지 않는 값을 담습니다. DOM 요소를 직접 다룰 때나, 타이머 ID처럼 화면에 그리지 않는 값을 보관할 때 씁니다.
import { useEffect, useRef } from "react";
export default function TextInputWithFocusButton() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleClick = () => {
if (inputRef.current) {
alert(`Current input value: ${inputRef.current.value}`);
inputRef.current.focus();
}
};
return (
<div>
<input type="text" ref={inputRef} placeholder="Focus me on mount" />
<button onClick={handleClick}>Show Value & Focus</button>
</div>
);
}useRef<HTMLInputElement>(null)은 current가 HTMLInputElement | null인 RefObject를 돌려줍니다. React가 요소를 화면에 붙인 뒤에야 current에 요소가 들어가고 요소가 사라지면 다시 null이 되므로, 쓰기 전에 null인지 검사합니다. inputRef.current!.focus()처럼 단언하면 검사는 통과하지만 요소가 생기는 것은 아니어서, 요소가 없는 시점에는 실행 중 오류가 납니다.
@types/react 19에서는 useRef에 인수를 반드시 넘겨야 하고, 돌려주는 RefObject의 current는 언제나 바꿀 수 있는 속성입니다. 또 ref 콜백이 정리 함수를 돌려줄 수 있게 되면서, ref={(el) => (saved = el)}처럼 값을 돌려주는 화살표 함수는 진단되므로 중괄호로 감싸 아무것도 돌려주지 않게 씁니다.
직접 만든 훅
여러 컴포넌트에서 같은 상태 로직을 쓰고 싶다면, use로 시작하는 함수로 꺼내 직접 훅을 만듭니다. 직접 만든 훅은 다른 훅을 부르는 보통의 함수이므로, 매개변수와 반환값에 타입을 붙이면 그 훅을 쓰는 모든 컴포넌트가 같은 약속을 따릅니다.
import { useCallback, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
interface UseCounterOptions {
initialValue?: number;
min?: number;
max?: number;
}
interface UseCounterReturn {
count: number;
increment: (step?: number) => void;
decrement: (step?: number) => void;
reset: () => void;
setCount: Dispatch<SetStateAction<number>>;
}
export default function useCounter({ initialValue = 0, min, max }: UseCounterOptions = {}): UseCounterReturn {
const [count, setCount] = useState(initialValue);
const increment = useCallback(
(step = 1) => {
setCount((prev) => (max !== undefined ? Math.min(prev + step, max) : prev + step));
},
[max],
);
const decrement = useCallback(
(step = 1) => {
setCount((prev) => (min !== undefined ? Math.max(prev - step, min) : prev - step));
},
[min],
);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset, setCount };
}import useCounter from "../hooks/useCounter";
export default function CustomCounter() {
const { count, increment, decrement, reset } = useCounter({ initialValue: 5, min: 0, max: 10 });
return (
<div>
<h2>Custom Counter ({count})</h2>
<button onClick={() => increment(2)}>Increment by 2</button>
<button onClick={() => decrement(3)}>Decrement by 3</button>
<button onClick={reset}>Reset</button>
</div>
);
}setCount의 타입 Dispatch<SetStateAction<number>>는 useState의 갱신 함수 타입으로, 새 값이나 이전 값을 받아 새 값을 돌려주는 함수를 받습니다. useCallback의 콜백에서 step = 1은 기본값으로 number로 추론되는데, strict에서는 이렇게 콜백의 매개변수 타입을 정할 수 있어야 하므로 기본값이 없다면 타입을 직접 적어야 합니다. onClick={reset}처럼 reset을 바로 넘길 수 있는 것은 매개변수가 없는 함수이기 때문이며, increment를 바로 넘기면 클릭 이벤트 객체가 step 자리로 넘어가게 되어 타입이 맞지 않는다는 진단이 나오므로, 화살표 함수로 감싸 숫자를 넘겼습니다.
useCounter를 부르는 컴포넌트는 저마다 자기 상태를 갖습니다. 같은 훅을 쓴다고 상태가 공유되지는 않으므로, CustomCounter를 두 개 놓으면 두 카운터는 따로 움직입니다. 또 이 훅은 increment에서는 상한만, decrement에서는 하한만 적용하므로, 음수 step을 넘기거나 그대로 노출한 setCount를 쓰면 범위를 벗어날 수 있습니다.
값을 어느 훅에 둘까
훅을 고를 때는 먼저 그 값이 어떤 성격인지, 누가 갖고 어디까지 나누어야 하는지를 정합니다.
HOOK CHOICE / OWNERSHIP AND LIFETIME
저장·공유·외부 동기화의 필요에 맞게 Hook을 조합한다
파생 계산, 지역 상태, 공유 범위, 렌더와 무관한 가변값, 외부 작업은 서로 다른 판단입니다. 한 컴포넌트가 여러 도구를 함께 쓸 수 있으며, 공유 여부나 Effect가 상태 전이 다음 단계인 것은 아닙니다.
DERIVED VALUE
Props·State로 계산 가능한가
필터 결과처럼 이미 가진 값에서 계산할 수 있으면 렌더 중 계산합니다. 값이 비싸다는 측정 근거가 있을 때 useMemo를 고려하며 실행 횟수를 보장하는 계약으로 쓰지 않습니다.
STATE TRANSITION
지역 사실과 전이를 저장
useState로 값을 저장합니다. 여러 사건이 관련 상태를 바꾸는 복잡한 전이는 useReducer로 모을 수 있습니다. reducer는 이 절의 실제 예제에는 없는 선택 확장입니다.
SHARING SCOPE
Context는 전달 범위
Provider가 소유한 state를 subtree에 전달할 수 있습니다. 본문 ThemeContext의 undefined guard는 Provider 없이 사용한 경우를 오류로 드러냅니다. Context 자체가 상태 저장 API는 아닙니다.
MUTABLE REFERENCE
ref 변경은 렌더를 요청하지 않는다
DOM 참조나 렌더에 쓰지 않는 가변값을 보관합니다. 본문 inputRef.current는 null 가능성을 검사한 뒤 focus합니다. 화면을 바꿀 값이면 ref만 갱신해서는 충분하지 않습니다.
EXTERNAL SYSTEM
Effect로 외부와 동기화
커밋 후 필요한 연결을 setup하고 수명에 맞춰 cleanup합니다. 상태 복잡도·공유 여부와 별개로 판단하며 클릭에 직접 대응하는 로직은 해당 이벤트 handler에서 처리할 수 있습니다.
REUSE LOGIC
커스텀 Hook은 로직을 재사용
각 useCounter 호출의 state는 독립됩니다. 예제는 increment의 상한·decrement의 하한만 적용하며 음수 step·외부 setCount까지 양쪽 범위 불변식을 강제하지 않습니다.
소유자와 공유 범위를 먼저 정하고 필요한 도구를 조합합니다. 같은 커스텀 Hook을 호출해도 하나의 state가 자동 공유되지는 않습니다.
그림의 기준을 정리하면 다음과 같습니다. 이미 가진 props나 상태에서 계산할 수 있는 값은 상태로 두지 않고 렌더링 중에 계산하며, 계산이 비싸다는 측정 결과가 있을 때 useMemo를 고려합니다. 컴포넌트가 직접 기억해야 하는 값은 useState에 두고, 여러 사건이 관련된 값들을 함께 바꾸는 복잡한 전이는 useReducer로 모을 수 있습니다.
여러 컴포넌트가 같은 값을 봐야 하면 그 값을 가진 컴포넌트가 Context로 하위 트리에 전달하고, 화면에 그리지 않는 가변값이나 DOM 요소는 ref에 둡니다. 바깥 시스템과의 동기화는 Effect에 두되, 클릭처럼 특정 사건에 대한 처리는 이벤트 핸들러에서 합니다. 직접 만든 훅은 이 도구들을 묶은 로직을 다시 쓰는 방법이지, 상태를 공유하는 방법이 아닙니다.
정리
useState로 관련된 상태를 따로 두면 불가능해야 할 조합이 생길 수 있으며,catch의 오류는unknown이므로 좁혀서 씁니다.useEffect의 설정 함수는 정리 함수만 돌려줄 수 있으므로async함수를 바로 넘기지 않고, 정리 함수는 설정 함수가 시작한 작업을 실제로 해제해야 합니다.- 정리 함수는 의존성이 바뀔 때와 컴포넌트가 사라질 때 실행되며, 개발 모드의
StrictMode는 설정, 정리, 설정을 한 번 더 실행해 이를 확인합니다. - Provider 밖에서 쓰면 안 되는 Context는 기본값을
undefined로 두고, 그것을 검사해 오류로 바꾸는 훅으로 감쌉니다. useRef<HTMLInputElement>(null)의current는null일 수 있으므로 쓰기 전에 검사하고,@types/react19에서useRef는 인수가 필요합니다.- 직접 만든 훅은 로직을 다시 쓰는 방법이며, 훅을 부르는 컴포넌트마다 상태가 따로 생깁니다.
연습 문제
1. 카운터의 변화
CustomCounter에서 Increment by 2를 세 번, Decrement by 3을 세 번 누른 뒤 Reset을 눌렀습니다. 단계마다 count는 얼마인가요? 노출된 setCount(99)를 직접 부르면 상한 10이 적용되나요?
해설 보기
5에서 시작해 7, 9가 되고, 세 번째 증가는 11 대신 상한 10이 됩니다. 이어서 7, 4, 1로 줄고, Reset으로 처음 값인 5로 돌아갑니다. setCount는 useState의 갱신 함수를 그대로 노출한 것이어서 상한을 거치지 않으므로, setCount(99)를 부르면 99가 됩니다. 범위가 반드시 지켜져야 한다면 setCount를 노출하지 말고 범위를 검사하는 함수만 내보냅니다.
2. 같은 훅과 같은 Context
CustomCounter를 두 개 놓고 첫 번째에서만 Increment by 2를 한 번 눌렀습니다. 두 카운터의 값은 얼마인가요? ThemeApp의 두 버튼 가운데 하나를 누른 경우와 무엇이 다른가요?
해설 보기
두 카운터는 각자 useCounter를 불러 따로 상태를 가지므로, 첫 번째는 7, 두 번째는 5입니다. ThemeApp의 두 버튼은 같은 ThemeProvider의 상태를 Context로 읽으므로, 한 버튼을 누르면 둘 다 Toggle Theme (dark)가 됩니다. 같은 훅을 쓰는 것은 로직을 다시 쓰는 것이고, 같은 값을 보려면 그 값을 가진 쪽이 Context 같은 방법으로 전달해야 합니다.
3. 정리 함수가 실행되는 때
DataFetcherSafe를 shouldFail={false}로 띄우고 0.2초 뒤에 부모가 shouldFail={true}로 바꾸었습니다. 화면에 연습 성공이 잠깐이라도 나타나나요? 같은 일을 첫 번째 DataFetcher 방식, 즉 정리 함수가 로그만 남기는 구조로 만들면 어떻게 될 수 있나요?
해설 보기
나타나지 않습니다. 의존성이 바뀌면 React가 이전 정리 함수로 첫 타이머를 취소한 뒤 새 타이머를 예약하므로, 0.5초 뒤에는 새 값의 Error: 연습 실패만 보입니다. 정리 함수가 로그만 남기면 첫 타이머가 그대로 남아 이전 값의 결과를 반영할 수 있고, 두 타이머가 끝나는 순서에 따라 화면이 이전 값의 결과로 덮일 수도 있습니다.
다음 글 고급 타입 패턴과 Best Practices에서는 판별 유니온으로 불가능한 UI 상태를 막고, 제네릭 컴포넌트와 유틸리티 타입, satisfies를 컴포넌트에 적용합니다.