JSX & 컴포넌트
함수 컴포넌트
React 컴포넌트는 JSX를 반환하는 JavaScript 함수입니다. 컴포넌트 이름은 대문자로 시작해야 합니다(소문자 = HTML 태그). Props는 속성으로 전달되고 매개변수에서 구조 분해됩니다. JSX는 React.createElement()의 구문적 설탕입니다. 항상 단일 root 요소를 반환하세요(또는 Fragment 사용). 컴포넌트는 순수해야 합니다 - 같은 props = 같은 출력.
// Basic function component
function Welcome({ name }) {
return <h1>Hello, {name}!</h1>;
}
// Arrow function component
const Greeting = ({ name = 'Guest' }) => (
<p>Welcome, {name}!</p>
);
// Composing components
function App() {
return (
<div>
<Welcome name="Alice" />
<Welcome name="Bob" />
<Greeting />
</div>
);
}JSX 표현식
JSX는 중괄호 {} 안에 JavaScript 표현식을 포함할 수 있습니다. 변수, 함수 호출, 삼항 연산자, 값을 반환하는 모든 표현식을 넣을 수 있습니다. 문(if, for, switch)은 직접 허용되지 않습니다 - 삼항이나 IIFE를 사용하세요. 불린 값(true, false), null, undefined는 아무것도 렌더링하지 않습니다. 숫자와 문자열은 텍스트로 렌더링됩니다. 객체는 유효한 React 자식이 아닙니다.
function Expression({ user, items }) {
const fullName = user.first + ' ' + user.last;
const itemCount = items.length;
return (
<div>
{/* Expressions in curly braces */}
<h1>{fullName}</h1>
<p>{itemCount} items</p>
{/* Conditional */}
<p>{itemCount > 0 ? 'In stock' : 'Sold out'}</p>
{/* Method calls */}
<p>{fullName.toUpperCase()}</p>
{/* Numbers and booleans render as nothing */}
<p>{false}{null}{undefined}</p>
</div>
);
}Fragment & JSX 리스트
Fragment(<>...</>)는 추가 DOM 노드 없이 여러 요소를 그룹화합니다 - <div>로 감싸는 것보다 깔끔합니다. key를 전달해야 할 때 <Fragment key={...}>를 사용하세요. JSX 요소의 배열은 고유한 key prop이 필요합니다. 정적 리스트의 경우 배열 인덱스를 key로 사용할 수 있지만, 동적 리스트의 경우 렌더링 버그를 방지하기 위해 안정적인 ID를 사용하세요. Fragment는 불필요한 래퍼 요소를 줄여 성능을 향상합니다.
// Fragment: group without extra DOM node
function App() {
return (
<>
<header>Header</header>
<main>Content</main>
<footer>Footer</footer>
</>
);
}
// Array of elements (needs keys)
function List() {
const fruits = ['Apple', 'Banana', 'Cherry'];
return (
<ul>
{fruits.map((fruit, i) => (
<li key={i}>{fruit}</li>
))}
</ul>
);
}조건부 렌더링
React는 다양한 조건부 렌더링 패턴을 제공합니다. if/else 로직을 위한 early return. either/or를 위한 삼항(cond ? A : B). 표시/숨기기를 위한 논리 AND(cond && <Component/>). 복잡한 분기를 위한 IIFE. JSX에 복잡한 로직을 임베드하지 마세요 - 변수나 헬퍼 함수로 추출하세요. switch 문의 경우, 조회 객체를 사용하거나 별도의 함수로 추출하세요. 거짓 값(0, '')은 렌더링되므로, 숫자의 경우 && 대신 삼항을 사용하세요.
function Greeting({ isLoggedIn, user }) {
// 1. If/else (use early return)
if (!isLoggedIn) return <Login />;
// 2. Ternary operator
return (
<div>
{user ? <Dashboard user={user} /> : <Loading />}
{/* 3. Logical AND (render if truthy) */}
{user.isAdmin && <AdminPanel />}
{/* 4. IIFE for complex logic */}
{(() => {
if (user.role === 'admin') return <Admin />;
if (user.role === 'mod') return <Mod />;
return <User />;
})()}
</div>
);
}Children & Render Props
children prop은 여는 태그와 닫는 태그 사이의 요소를 포함합니다 - 조합 가능 한 컴포넌트(카드, 모달, 레이아웃)에 필수적. render props는 데이터를 받고 JSX를 반환하는 함수를 prop으로 전달합니다 - 로직 공유를 위한 HOC와 hook의 대안. hook으로 render props가 덜 일반적이지만, 컴포넌트 주입 패턴에 여전히 유용합니다. children은 명시적으로 전달할 필요가 없는 특별한 prop입니다.
// children prop: content between tags
function Card({ title, children }) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
// Usage
<Card title="Profile">
<p>Name: Alice</p>
<p>Age: 30</p>
</Card>
// Render prop pattern
function DataProvider({ render }) {
const data = fetchData();
return <div>{render(data)}</div>;
}Props
Props 전달
Props는 부모에서 자식으로 전달되는 읽기 전용 데이터입니다. 모든 JavaScript 값이 될 수 있습니다: 문자열, 숫자, 불린, 배열, 객체, 또는 함수. 문자열 값은 따옴표를 사용하고(name='Alice'), 다른 모든 값은 중괄호를 사용합니다(age={30}). 함수를 prop으로 사용하면 자식에서 부모로의 통신(콜백)이 가능합니다. Props는 아래로 흐릅니다 - 자식은 props를 수정할 수 없습니다. 양방향 데이터 바인딩의 경우, 공통 부모로 state를 끌어올리세요.
// Parent passes props to child
function App() {
return (
<User
name="Alice"
age={30}
isActive={true}
tags={['admin', 'dev']}
onClick={() => console.log('clicked')}
/>
);
}
// Child receives props
function User({ name, age, isActive, tags, onClick }) {
return (
<div onClick={onClick}>
<h1>{name}</h1>
<p>Age: {age}</p>
<p>Status: {isActive ? 'Active' : 'Inactive'}</p>
</div>
);
}기본 & 선택적 Props
기본 prop 값은 구조 분해(param = defaultValue)로 설정됩니다. prop이 전달되지 않으면 undefined입니다. 선택적 prop을 조건부로 렌더링하려면 short-circuit(bio && <p>)이나 삼항을 사용하세요. PropTypes(레거시)나 TypeScript 인터페이스로 개발 시 prop 타입을 검증할 수 있습니다. defaultProps(클래스 컴포넌트)는 함수 컴포넌트에서 더 이상 사용되지 않습니다 - 대신 구조 분해 기본값을 사용하세요.
// Default values via destructuring
function Button({ color = 'blue', size = 'md', children }) {
return (
<button className={'btn btn-' + color + ' btn-' + size}>
{children}
</button>
);
}
// Optional props (undefined if not passed)
function Profile({ name, bio }) {
return (
<div>
<h1>{name}</h1>
{bio && <p>{bio}</p>}
</div>
);
}
// Usage
<Button>Click</Button> {/* color='blue', size='md' */}
<Profile name="Alice" /> {/* bio is undefined */}Spread & Rest Props
spread 연산자(...props)는 모든 prop을 자식 요소로 전달합니다 - 래퍼 컴포넌트(HOC, styled component)에 유용. rest 연산자는 특정 prop을 구조 분해한 후 남은 prop을 수집합니다. 이 패턴은 래퍼 컴포넌트가 알 수 없는 prop을 DOM 요소로 전달하는 디자인 시스템에서 일반적입니다. 주의: spread는 명시적 속성을 재정의할 수 있습니다 - 순서가 중요합니다({...props} className='x' vs className='x' {...props}).
// Spread: pass all props to child
function Input(props) {
return <input {...props} className="input" />;
}
// Usage
<Input type="text" placeholder="Name" value="Alice" />
// Rest: collect remaining props
function Button({ label, ...rest }) {
return <button {...rest}>{label}</button>;
}
// Selective spreading
function Card({ title, children, ...divProps }) {
return (
<div {...divProps}>
<h2>{title}</h2>
{children}
</div>
);
}Prop Types & TypeScript
TypeScript 인터페이스는 prop에 대한 컴파일 타임 타입 검사를 제공합니다 - 새 React 프로젝트에 권장되는 접근 방식. 선택적 prop은 ?를 사용합니다(isActive?: boolean). PropTypes는 런타임 검증(개발 중에만)을 제공하며 TypeScript가 없는 JavaScript 프로젝트에 유용합니다. isRequired는 prop이 제공되었는지 보장합니다. TypeScript는 런타임 전에 타입 오류를 잡아내어 대규모 코드베이스에 우수합니다.
// TypeScript interface (recommended)
interface UserProps {
name: string;
age: number;
isActive?: boolean; // optional
onClick: (id: number) => void;
}
function User({ name, age, isActive = true, onClick }: UserProps) {
return <div onClick={() => onClick(1)}>{name}, {age}</div>;
}
// PropTypes (runtime checking, legacy)
import PropTypes from 'prop-types';
User.propTypes = {
name: PropTypes.string.isRequired,
age: PropTypes.number,
isActive: PropTypes.bool,
};Prop Drilling & Context
prop drilling은 prop을 사용하지 않는 여러 컴포넌트 계층을 통과할 때 발생합니다. 2-3 수준의 경우 허용 가능합니다. 더 깊은 트리의 경우 Context API, 상태 관리 라이브러리(Redux, Zustand), 또는 컴포넌트 조합을 사용하세요. 조합(컴포넌트를 prop이나 children으로 전달)이 종종 Context보다 더 우아하게 drilling을 해결합니다. 질문: 모든 중간 컴포넌트가 이 데이터가 필요한가? 그렇지 않다면, 컴포넌트 구조를 재고하세요.
// Prop drilling: passing through multiple levels
function App() {
const [user, setUser] = useState(null);
return <Layout user={user} />;
}
function Layout({ user }) {
return <Sidebar user={user} />;
}
function Sidebar({ user }) {
return <UserInfo user={user} />;
}
// Solution: Context API (see State Management section)
// Avoid drilling more than 2-3 levelsuseState & State
기본 useState
useState는 함수 컴포넌트에 state를 추가하는 기본 hook입니다. 배열을 반환합니다: [현재값, setter함수]. 초기 값은 어떤 타입이든 될 수 있습니다. setter를 호출하면 새 값으로 리렌더가 트리거됩니다. state 업데이트는 비동기입니다 - setCount 호출 후 값이 즉시 변경되지 않습니다. 각 컴포넌트 인스턴스는 자체 독립적인 state를 가집니다. setter는 안정적입니다(렌더 간 같은 참조).
import { useState } from 'react';
function Counter() {
// [currentValue, setterFunction] = useState(initialValue)
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}함수형 업데이트
새 state가 이전 state에 의존할 때, 함수형 업데이트를 사용하세요: setCount(prev => prev + 1). 이는 여러 업데이트가 일괄 처리되더라도 최신 state로 작업하고 있음을 보장합니다. 함수형 업데이트 없이는 빠른 연속 호출이 오래된 state를 사용할 수 있습니다. React 18은 자동으로 state 업데이트를 일괄 처리하므로(promise와 timeout에서도), 정확성을 위해 함수형 업데이트가 필수적입니다.
function Counter() {
const [count, setCount] = useState(0);
// BAD: may not work correctly with rapid updates
const incrementBad = () => setCount(count + 1);
// GOOD: functional update uses previous state
const incrementGood = () => setCount(prev => prev + 1);
// Batch updates
const addThree = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
};
return <button onClick={addThree}>Count: {count}</button>;
}객체 & 배열과 State
state를 직접 변경하지 마세요 - 항상 새 객체/배열을 만드세요. 객체의 경우 spread 연산자로 기존 속성을 복사하세요: {...prev, [field]: value}. 배열의 경우 추가는 spread([...prev, newItem]), 제거는 filter, 업데이트는 map을 사용하세요. React는 변경을 감지하기 위해 참조를 비교합니다 - 변경된 객체는 같은 참조를 가지므로 React가 리렌더하지 않습니다. 이것이 초보자를 위한 React 버그의 #1 원인입니다.
function Form() {
const [form, setForm] = useState({ name: '', email: '', age: 0 });
// BAD: mutates state directly
// form.name = 'Alice'; setForm(form);
// GOOD: spread to create new object
const updateField = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }));
};
return (
<input
value={form.name}
onChange={e => updateField('name', e.target.value)}
/>
);
}
// Array state
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => setTodos(prev => [...prev, { id: Date.now(), text }]);
const removeTodo = (id) => setTodos(prev => prev.filter(t => t.id !== id));
}지연 초기 State
초기 state가 비용이 많이 드는 계산을 필요로 하는 경우, useState에 함수를 전달하세요(지연 초기화). 함수는 첫 렌더에서만 실행되고, 매 리렌더에서 실행되지 않습니다. 이는 localStorage 파싱, IndexedDB에서 가져오기, 또는 CPU 집약적 설정에 중요합니다. 함수 형태: useState(() => initialValue). 단순 값(숫자, 문자열)의 경우, 값을 직접 전달하세요 - 지연 초기화는 불필요합니다.
import { useState } from 'react';
function ExpensiveInit() {
// BAD: runs on every render (even though result is ignored)
const [data, setData] = useState(computeExpensiveValue());
// GOOD: lazy initialization — function runs only once
const [data2, setData2] = useState(() => computeExpensiveValue());
// Reading from localStorage
const [user, setUser] = useState(() => {
const saved = localStorage.getItem('user');
return saved ? JSON.parse(saved) : null;
});
return <div>{data2}</div>;
}다중 State 변수
하나의 큰 객체보다 독립적인 값에 대해 여러 useState 호출을 사용하세요. 이는 업데이트를 단순하게 만들고(spread 불필요) 불필요한 리렌더를 방지합니다. 관련 값을 단일 state 객체로 그룹화하세요(예: 폼 필드). 여러 하위 값이 있는 복잡한 state 로직의 경우 useReducer를 대신 고려하세요. 경험칙: state 업데이트가 독립적이면 별도의 useState를; 관련/상호 의존적이면 useReducer나 단일 객체를 사용하세요.
function LoginForm() {
// Multiple independent state variables
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
setIsSubmitting(true);
// ... validation and submission
};
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" value={password}
onChange={e => setPassword(e.target.value)} />
<button disabled={isSubmitting}>Submit</button>
</form>
);
}useEffect & 부수 효과
기본 useEffect
useEffect는 렌더 후 부수 효과를 수행합니다. effect 함수는 컴포넌트가 페인트 후 실행됩니다. cleanup 함수(반환된)는 다음 effect 전과 언마운트 시 실행됩니다 - 타이머, 구독, 리스너 정리에 필수. 의존성 배열은 effect가 재실행되는 시점을 제어합니다: [] = 마운트 시 한 번, [dep] = dep 변경 시, 배열 없음 = 매 렌더. 메모리 누수를 방지하기 위해 항상 cleanup하세요.
import { useState, useEffect } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
// Runs after every render
useEffect(() => {
const interval = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
// Cleanup function runs before next effect or unmount
return () => clearInterval(interval);
}, []); // empty array = run once on mount
return <p>Seconds: {seconds}</p>;
}의존성 배열
의존성 배열은 useEffect 동작에 중요합니다. 빈 배열 [] = 마운트 전용(componentDidMount 같음). 의존성 [a, b] = 마운트 시와 a나 b 변경 시 실행. 배열 없음 = 매 렌더(드물게 원함). 의존성 누락은 오래된 closure를 유발합니다. 불필요한 의존성 포함은 과도한 재실행을 유발합니다. 실수를 잡기 위해 exhaustive-deps ESLint 규칙을 사용하세요. effect에서 사용된 컴포넌트 범위의 모든 값이 deps에 있어야 합니다.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// Runs once on mount (empty deps)
useEffect(() => {
console.log('Component mounted');
}, []);
// Runs when userId changes
useEffect(() => {
fetch('/api/users/' + userId)
.then(r => r.json())
.then(setUser);
}, [userId]); // re-run when userId changes
// Runs on every render (no deps) - rarely needed
useEffect(() => {
console.log('Every render');
});
return <div>{user?.name}</div>;
}Cleanup & 구독
cleanup은 구독, 이벤트 리스너, 타이머, WebSocket 연결에 필수적입니다. cleanup 없이는 메모리 누수와 중복 핸들러가 발생합니다. cleanup 함수는 (1) 다음 effect 재실행 전, (2) 컴포넌트 언마운트 시 실행됩니다. WebSocket/이벤트 리스너의 경우 항상 cleanup에서 제거하세요. effect에 의존하는 state의 경우, 이전 roomId의 오래된 데이터 표시를 방지하기 위해 cleanup에서 재설정하세요.
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const ws = new WebSocket('wss://chat.example.com/' + roomId);
ws.onmessage = (event) => {
setMessages(prev => [...prev, JSON.parse(event.data)]);
};
// Cleanup: close connection when roomId changes or unmount
return () => {
ws.close();
setMessages([]); // reset for new room
};
}, [roomId]);
// Window event listener
useEffect(() => {
const handleResize = () => console.log(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <div>{messages.length} messages</div>;
}데이터 가져오기
useEffect에서 데이터 가져오기는 언마운트 후 state 설정을 방지하기 위해 취소 플래그가 필요합니다(React 경고 발생). 'cancelled' 플래그는 컴포넌트가 여전히 마운트된 경우에만 setUsers/setError/setLoading이 실행되도록 보장합니다. 프로덕션 앱의 경우, 캐싱, 중복 제거, 경쟁 조건을 자동으로 처리하는 데이터 가져오기 라이브러리(React Query, SWR) 사용을 고려하세요. 빈 의존성 배열 []은 마운트 시 한 번 가져오기를 보장합니다.
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
try {
setLoading(true);
const res = await fetch('/api/users');
const data = await res.json();
if (!cancelled) setUsers(data);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchData();
return () => { cancelled = true; };
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}useLayoutEffect vs useEffect
useEffect는 브라우저가 페인트 후 비동기로 실행됩니다 - DOM 요소를 측정하는 경우 사용자가 짧은 플래시를 볼 수 있습니다. useLayoutEffect는 DOM 변경 후 but 페인트 전에 동기로 실행됩니다 - 시각적 깜빡임을 방지합니다. 레이아웃에 영향을 주는 DOM 측정(getBoundingClientRect, scroll position)에 useLayoutEffect를 사용하세요. 나머지 모든 것에는 useEffect를 사용하세요(페인트를 차단하지 않음). 서버에서 useLayoutEffect는 경고합니다 - useIsomorphicLayoutEffect 패턴을 사용하세요.
import { useState, useEffect, useLayoutEffect } from 'react';
function Tooltip({ text }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
// useEffect: runs AFTER paint (user may see flash)
useEffect(() => {
const rect = document.getElementById('tip').getBoundingClientRect();
setPosition({ x: rect.x, y: rect.y });
}, [text]);
// useLayoutEffect: runs BEFORE paint (no flash)
useLayoutEffect(() => {
const rect = document.getElementById('tip').getBoundingClientRect();
setPosition({ x: rect.x, y: rect.y });
}, [text]);
return <div id="tip" style={{ left: position.x, top: position.y }}>{text}</div>;
}useRef, useMemo & useCallback
useRef 기본
useRef는 렌더 간 지속되는 변경 가능한 객체 { current: value }를 반환합니다. state와 달리 ref.current 변경은 리렌더를 트리거하지 않습니다. 일반적 사용: (1) DOM 요소 접근(ref 속성 통해), (2) 렌더링에 영향을 주지 않는 변경 가능 값 저장(타이머, 이전 값), (3) 콜백에서 사용할 최신 값 저장. ref 객체는 렌더 간 같은 식별자를 가집니다. 초기 값이 useRef(initialValue)에 전달됩니다.
import { useRef } from 'react';
function FocusInput() {
// ref to access DOM element
const inputRef = useRef(null);
const focus = () => inputRef.current.focus();
const clear = () => {
inputRef.current.value = '';
inputRef.current.focus();
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focus}>Focus</button>
<button onClick={clear}>Clear</button>
</div>
);
}변경 가능 값에 useRef
useRef는 리렌더를 트리거하지 않고 렌더 간 지속되는 변경 가능 값을 저장합니다. 이는 타이머 ID, WebSocket 참조, 이전 state 추적, 렌더 카운팅에 완벽합니다. ref.current 변경이 리렌더를 유발하지 않으므로, 변경 시 UI가 업데이트되지 않습니다 - UI에 영향을 주어야 하는 값에는 state를 사용하세요. 렌더 카운트 패턴(ref.current++)은 디버깅에 유용하지만 프로덕션 로직에 사용하지 마세요.
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
const renderCount = useRef(0);
// Track render count (doesn't trigger re-render)
renderCount.current++;
const start = () => {
if (intervalRef.current) return;
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
return (
<div>
<p>{seconds}s (render #{renderCount.current})</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}useMemo
useMemo는 계산된 값을 메모이제이션(캐싱)하고, 의존성이 변경될 때만 재계산합니다. 비용이 많이 드는 계산(필터링, 정렬, 복잡한 수학)에 사용하여 매 렌더마다 재실행을 방지하세요. 의존성 배열은 useEffect처럼 작동합니다. 과용은 성능을 해칠 수 있습니다(메모이제이션 자체 오버헤드) - 진정으로 비용이 많이 드는 작업만 메모이제이션하세요. useMemo는 또한 자식 리렌더를 방지하기 위해 객체 참조를 보존하는 데 유용합니다.
import { useState, useMemo } from 'react';
function ProductList({ products, filter }) {
const [search, setSearch] = useState('');
// Memoize expensive computation
const filtered = useMemo(() => {
console.log('Filtering...');
return products
.filter(p => p.category === filter)
.filter(p => p.name.includes(search));
}, [products, filter, search]); // recompute only when these change
// Memoize a value
const totalPrice = useMemo(() =>
filtered.reduce((sum, p) => sum + p.price, 0),
[filtered]);
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
<p>Total: ${totalPrice}</p>
{filtered.map(p => <div key={p.id}>{p.name}</div>)}
</div>
);
}useCallback
useCallback은 함수를 메모이제이션하여 의존성이 변경되지 않는 한 렌더 간 같은 참조를 반환합니다. 이는 memo()로 감싼 메모이제이션된 자식 컴포넌트의 불필요한 리렌더를 방지합니다. useCallback 없이는 부모의 매 렌더마다 새 함수 참조가 생성되어 memo() 자식이 리렌더됩니다. 최적화된 자식 컴포넌트에 콜백을 전달할 때 useCallback을 사용하세요. useMemo와 마찬가지로 과용하지 마세요 - 메모이제이션된 자식에 prop으로 전달되는 함수에만 사용하세요.
import { useState, useCallback, memo } from 'react';
// Memoized child component
const Button = memo(function Button({ onClick, label }) {
console.log('Button rendered');
return <button onClick={onClick}>{label}</button>;
});
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// Without useCallback: new function every render
// const handleClick = () => setCount(c => c + 1);
// With useCallback: stable function reference
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []); // empty deps = stable forever
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<Button onClick={handleClick} label="Click" />
<p>Count: {count}</p>
</div>
);
}Ref 전달
forwardRef는 부모 컴포넌트가 자식 컴포넌트의 DOM 요소로 ref를 전달할 수 있게 합니다. useImperativeHandle은 ref가 노출하는 것을 사용자 정의합니다 - DOM 노드 대신 특정 메서드(focus, clear, getValue)를 노출할 수 있습니다. 이는 명령형 API가 있는 재사용 가능한 입력 컴포넌트를 만드는 데 유용합니다. React 19는 ref를 단순화했습니다(ref는 이제 일반 prop), but 라이브러리에는 여전히 forwardRef가 필요합니다. 명령형 핸들 과용을 피하세요 - 선언적 prop을 선호하세요.
import { useRef, forwardRef, useImperativeHandle } from 'react';
// forwardRef: pass ref to child component
const FancyInput = forwardRef(function FancyInput(props, ref) {
return <input ref={ref} className="fancy" {...props} />;
});
// useImperativeHandle: expose specific methods
const CustomInput = forwardRef(function CustomInput(props, ref) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
clear: () => { inputRef.current.value = ''; },
getValue: () => inputRef.current.value,
}));
return <input ref={inputRef} />;
});
// Usage
function App() {
const ref = useRef(null);
return (
<>
<CustomInput ref={ref} />
<button onClick={() => ref.current.focus()}>Focus</button>
<button onClick={() => ref.current.clear()}>Clear</button>
</>
);
}useReducer & Context
useReducer 기본
useReducer는 복잡한 state 로직을 위한 useState의 대안입니다. reducer는 순수 함수입니다: (state, action) => newState. action은 무엇이 일어났는지 설명하고, reducer는 state를 어떻게 업데이트할지 결정합니다. 이 패턴은 state 전환을 예측 가능하고 테스트 가능하게 만듭니다. dispatch는 안정적입니다(같은 참조). 항상 새 state 객체를 반환하세요(변경 금지). default 케이스는 알 수 없는 action에 대해 오류를 throw해야 합니다. state에 여러 하위 값이 있거나 다음 state가 복잡한 로직에 의존할 때 useReducer를 사용하세요.
import { useReducer } from 'react';
// Reducer function: (state, action) => newState
function counterReducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
case 'set':
return { count: action.payload };
default:
throw new Error('Unknown action: ' + action.type);
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
<button onClick={() => dispatch({ type: 'set', payload: 10 })}>Set 10</button>
</div>
);
}복잡한 Reducer
복잡한 reducer는 여러 관련 state 조각을 관리합니다. 각 action 타입은 특정 state 전환을 처리합니다. 항상 이전 state를 spread({...state})하여 관련 없는 필드를 보존하세요. 중첩된 업데이트(todo 토글 같은)의 경우 map으로 업데이트된 항목이 있는 새 배열을 만드세요. reducer는 순수해야 합니다 - 부수 효과 없음, API 호출 없음. 테스트 가능성을 위해 reducer를 별도 파일로 추출하세요. 매우 복잡한 state의 경우 Redux Toolkit 같은 라이브러리를 고려하세요.
const initialState = {
todos: [],
filter: 'all',
loading: false,
};
function todoReducer(state, action) {
switch (action.type) {
case 'add':
return { ...state, todos: [...state.todos, action.todo] };
case 'toggle':
return {
...state,
todos: state.todos.map(t =>
t.id === action.id ? { ...t, done: !t.done } : t
),
};
case 'delete':
return { ...state, todos: state.todos.filter(t => t.id !== action.id) };
case 'set_filter':
return { ...state, filter: action.filter };
case 'set_loading':
return { ...state, loading: action.loading };
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
// dispatch({ type: 'add', todo: { id: 1, text: 'Learn React', done: false } })
}Context API
Context API는 prop drilling 없이 컴포넌트 트리 전체에 state를 공유합니다. createContext(defaultValue)로 만드세요. value prop과 함께 Provider로 소비자를 감싸세요. useContext(Context)로 소비하세요. Context 값 변경은 모든 소비자의 리렌더를 트리거합니다. 성능을 위해, 컴포넌트가 특정 context가 변경될 때만 리렌더하도록 context를 분할하세요(ThemeContext, UserContext). 기본 값은 Provider가 소비자를 감싸지 않을 때 사용됩니다.
import { createContext, useContext, useState } from 'react';
// 1. Create context with default value
const ThemeContext = createContext('light');
const UserContext = createContext(null);
// 2. Provider component
function App() {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<UserContext.Provider value={{ user, setUser }}>
<Page />
</UserContext.Provider>
</ThemeContext.Provider>
);
}
// 3. Consume context
function Page() {
const { theme } = useContext(ThemeContext);
const { user } = useContext(UserContext);
return <div className={'page theme-' + theme}>Hello {user?.name}</div>;
}Reducer와 Context
Context와 useReducer를 결합하면 가벼운 전역 상태 관리 시스템(미니 Redux)을 만듭니다. Provider는 state와 dispatch를 모두 노출합니다. 사용자 정의 hook(useStore)은 provider 외부에서 사용될 경우 오류 처리를 제공합니다. 이 패턴은 중간 규모 앱에 적합합니다. 빈번한 업데이트가 있는 매우 큰 앱의 경우, 모든 소비자의 리렌더를 방지하기 위해 context를 분할하거나 Redux/Zustand를 사용하는 것을 고려하세요.
import { createContext, useContext, useReducer } from 'react';
// Combine Context + useReducer for global state
const StoreContext = createContext(null);
function StoreProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StoreContext.Provider value={{ state, dispatch }}>
{children}
</StoreContext.Provider>
);
}
// Custom hook for easy consumption
function useStore() {
const context = useContext(StoreContext);
if (!context) throw new Error('useStore must be used within StoreProvider');
return context;
}
// Usage
function Component() {
const { state, dispatch } = useStore();
return <button onClick={() => dispatch({ type: 'action' })}>Click</button>;
}useContext 성능
context 값이 변경되면, 값의 작은 부분만 사용하더라도 모든 소비자가 리렌더됩니다. 최적화하려면: (1) 컴포넌트가 필요한 것만 구독하도록 context를 분할. (2) 값이 실제로 변경되지 않았을 때 리렌더를 방지하기 위해 useMemo로 context 값을 메모이제이션. (3) 세밀한 구독을 위해 selector 사용(use-context-selector 라이브러리). 고빈도 업데이트(마우스 위치 같은)의 경우, Context는 성능 문제를 일으킬 수 있습니다 - ref나 외부 저장소를 고려하세요.
// SPLIT contexts for performance
const ThemeContext = createContext();
const UserContext = createContext();
const CartContext = createContext();
// Each provider manages its own state
function App() {
return (
<ThemeProvider>
<UserProvider>
<CartProvider>
<App />
</CartProvider>
</UserProvider>
</ThemeProvider>
);
}
// Component only re-renders when its context changes
function ThemedButton() {
const { theme } = useContext(ThemeContext);
// Won't re-render when user or cart changes
return <button className={theme}>Button</button>;
}
// Memoize context value to prevent unnecessary re-renders
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}이벤트 & 폼
이벤트 처리
React 이벤트는 camelCase를 사용합니다(onClick, onclick이 아님). 이벤트 객체는 SyntheticEvent입니다(네이티브 이벤트의 래퍼). e.preventDefault()는 기본 동작을 중지합니다(폼 제출, 링크 내비게이션). e.stopPropagation()은 이벤트 버블링을 방지합니다. 핸들러에 매개변수를 전달하려면 화살표 함수를 사용하세요: onClick={() => handleDelete(id)}. 복잡한 핸들러를 인라인으로 정의하지 마세요 - 가독성을 위해 추출하세요. React 이벤트는 풀링됩니다(17 이전), 비동기 액세스가 필요하면 e.persist()를 호출하세요.
function App() {
// Click event
const handleClick = (e) => {
e.preventDefault();
console.log('Button clicked', e.target);
};
// With parameters (use arrow function)
const handleDelete = (id) => {
console.log('Delete item', id);
};
return (
<div>
<button onClick={handleClick}>Click</button>
<button onClick={() => handleDelete(42)}>Delete</button>
<div onMouseEnter={() => console.log('hover')}
onMouseLeave={() => console.log('leave')}>
Hover me
</div>
</div>
);
}제어 입력
제어 입력은 React state에 의해 값이 제어됩니다. value prop이 입력의 값을 설정하고, onChange가 state를 업데이트합니다. 이는 React를 폼 데이터의 '단일 진실의 원천'으로 만듭니다. 매 키 입력이 state 업데이트와 리렌더를 트리거합니다. 복 잡한 폼의 경우, 이것이 장황할 수 있습니다 - React Hook Form이나 Formik 같은 라이브러리를 고려하세요. 제어 입력은 실시간 검증과 동적 동작을 가능하게 합니다. React 경고를 방지하려면 항상 value와 함께 onChange를 사용하세요(또는 readOnly).
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
console.log('Email:', email, 'Password:', password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
);
}다중 필드 폼
여러 필드가 있는 폼의 경우, 단일 state 객체와 일반 handleChange 함수를 사용하세요. 각 입력의 name 속성이 state 키와 일치합니다. 핸들러는 계산된 속성 이름([name]: value)을 사용하여 올바른 필드를 업데이트합니다. 체크박스의 경우 value 대신 checked를 사용하세요. 이 패턴은 보일러플레이트를 크게 줄입니다. 파일 입력의 경우, 제어하지 않는 입력을 사용하세요(완전히 제어할 수 없음). 검증이 있는 복잡한 폼의 경우 React Hook Form을 고려하세요.
function RegistrationForm() {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
country: 'us',
agree: false,
});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log(formData);
};
return (
<form onSubmit={handleSubmit}>
<input name="username" value={formData.username} onChange={handleChange} />
<input name="email" type="email" value={formData.email} onChange={handleChange} />
<select name="country" value={formData.country} onChange={handleChange}>
<option value="us">USA</option>
<option value="uk">UK</option>
</select>
<label>
<input type="checkbox" name="agree" checked={formData.agree} onChange={handleChange} />
Agree to terms
</label>
<button type="submit">Register</button>
</form>
);
}비제어 입력
비제어 입력은 React state 없이 ref로 DOM 값에 직접 액세스합니다. defaultValue prop이 초기 값을 설정합니다(value가 아님). 이는 실시간 검증이나 동적 동작이 필요 없는 폼에 더 간단합니다. 파일 입력은 비제어여야 합니다(보안상 값이 읽기 전용). 비제어 입력은 React가 아닌 코드와 통합하는 데도 유용합니다. 절충안: 실시간으로 입력을 검증하거나 변환하기 어렵습니다. 대부분의 경우 제어 입력을 선호하세요.
import { useRef } from 'react';
function UncontrolledForm() {
const emailRef = useRef(null);
const passwordRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
console.log('Email:', emailRef.current.value);
console.log('Password:', passwordRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} type="email" defaultValue="" />
<input ref={passwordRef} type="password" defaultValue="" />
<button type="submit">Submit</button>
</form>
);
}
// File input (must be uncontrolled)
function FileUpload() {
const fileRef = useRef(null);
return <input ref={fileRef} type="file" />;
}검증 & 오류 처리
폼 검증은 제출 시 또는 매 변경 시 수행할 수 있습니다. validate 함수는 errors 객체를 반환합니다 - 비어 있으면 유효. 각 필드 옆에 조건부로 오류를 표시하세요. 더 나은 UX를 위해, 매 키 입력보다 blur(필드에서 포커스가 떠날 때) 시 검증하세요. React Hook Form + Zod, 또는 Formik + Yup 같은 라이브러리는 강력한 검증 스키마, 오류 관리, touch/blur 추적을 제공합니다. 항상 서버에서도 검증하세요 - 클라이언트 검증은 UX용, 보안용이 아닙니다.
function ValidatedForm() {
const [values, setValues] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const validate = () => {
const errs = {};
if (!values.email) errs.email = 'Email is required';
else if (!/\S+@\S+\.\S+/.test(values.email)) errs.email = 'Invalid email';
if (!values.password) errs.password = 'Password is required';
else if (values.password.length < 8) errs.password = 'Min 8 characters';
return errs;
};
const handleSubmit = (e) => {
e.preventDefault();
const errs = validate();
setErrors(errs);
if (Object.keys(errs).length === 0) {
console.log('Form valid', values);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={values.email}
onChange={e => setValues(v => ({ ...v, email: e.target.value }))} />
{errors.email && <span className="error">{errors.email}</span>}
<input type="password" value={values.password}
onChange={e => setValues(v => ({ ...v, password: e.target.value }))} />
{errors.password && <span className="error">{errors.password}</span>}
<button type="submit">Submit</button>
</form>
);
}리스트 & 조건부 렌더링
리스트 렌더링
.map()을 사용하여 배열을 JSX 요소로 변환하세요. 각 요소는 고유한 key prop이 필요합니다 - 배열 인덱스가 아닌 안정적인 ID(todo.id)를 사용하세요. key는 React가 효율적인 DOM 업데이트를 위해 변경된 항목(추가, 제거, 재정렬)을 식별하는 데 도움을 줍니다. 인덱스를 key로 사용하면 리스트 항목이 재정렬되거나 처음에 삽입될 때 버그가 발생합니다. 빈 리스트의 경우, 폴백 메시지를 렌더하세요. 매 렌더마다 재계산을 방지하기 위해 필터링/정렬된 리스트에 useMemo를 고려하세요.
function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span>{todo.text}</span>
<button onClick={() => toggle(todo.id)}>
{todo.done ? 'Undo' : 'Done'}
</button>
</li>
))}
</ul>
);
}
// Filtering and sorting
function FilteredList({ items, filter }) {
const visible = items
.filter(item => item.category === filter)
.sort((a, b) => a.name.localeCompare(b.name));
return (
<ul>
{visible.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}Key 설명
key는 형제(같은 부모) 간에 고유해야 하지만, 다른 리스트 간에는 반복될 수 있습니다. key는 React의 reconciliation 알고리즘을 돕습니다: key가 변경되면 React는 컴포넌트를 파괴하고 재생성합니다(state 손실). 인덱스 key의 경우, 처음에 항목을 삽입하면 모든 인덱스가 이동하여 React가 모든 것을 리렌더합니다. 안정적인 ID key의 경우, React는 새 항목만 렌더합니다. key는 전역적으로 고유할 필요가 없습니다 - 리스트 내에서만 고유하면 됩니다. 무작위 key(Math.random())를 사용하지 마세요 - 매 렌더마다 변경됩니다.
// GOOD: stable, unique keys
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
// BAD: index as key (causes bugs with reordering)
{todos.map((todo, index) => (
<TodoItem key={index} todo={todo} />
))}
// When index keys are OK:
// - Static list (never reordered/filtered)
// - List items have no state
// - List is never prepended to
// Key must be unique among siblings
function List() {
return (
<div>
{users.map(u => <User key={u.id} user={u} />)}
{posts.map(p => <Post key={p.id} post={p} />)}
{/* IDs can repeat across different lists */}
</div>
);
}조건부 렌더링 패턴
다양한 조건부 렌더링 패턴이 있습니다. early return은 가드 조건(loading, error, auth)에 가장 깔끔합니다. 요소 변수는 컴포넌트 중간의 if/else에 작동합니다. JSX에서 either/or를 위한 삼항(cond ? A : B). 표시/숨기기를 위한 논리 AND(cond && <X/>). switch 같은 동작을 위한 객체 조회. 중첩 삼항을 피하세요 - 변수나 컴포넌트로 추출하세요. 숫자의 경우 && 대신 삼항을 사용하세요(0 && <X/>은 0을 렌더).
function UserDashboard({ user, loading, error }) {
// 1. Early returns for loading/error states
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return <Login />;
// 2. Element variables
let greeting;
if (user.isAdmin) {
greeting = <h1>Welcome Admin {user.name}</h1>;
} else {
greeting = <h1>Welcome {user.name}</h1>;
}
return (
<div>
{greeting}
{/* 3. Ternary for either/or */}
{user.hasNotifications ? <NotificationBadge /> : null}
{/* 4. && for show/hide */}
{user.isAdmin && <AdminPanel />}
{/* 5. Switch via object lookup */}
{{ free: <FreePlan />, pro: <ProPlan />, enterprise: <EnterprisePlan /> }
[user.plan]}
</div>
);
}리스트 필터링 & 검색
검색/필터 가능한 리스트는 필터를 위한 useState와 성능을 위한 useMemo를 결합합니다. filter 함수는 query와 category를 모두 확인합니다. 항상 빈 상태(결과 없음)를 처리하세요. 큰 리스트(1000+ 항목)의 경우, 보이는 항목만 렌더하기 위해 virtualization(react-window, react-virtualized)을 고려하세요. API 호출을 위해 검색 입력을 debounce하세요. 대소문자 구분 없는 검색은 toLowerCase()를 사용합니다. 복잡한 필터링의 경우, 별도 함수나 사용자 정의 hook으로 추출하세요.
function SearchableList({ items }) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState('all');
// Memoize filtered results
const filtered = useMemo(() => {
return items.filter(item => {
const matchesQuery = item.name.toLowerCase().includes(query.toLowerCase());
const matchesCategory = category === 'all' || item.category === category;
return matchesQuery && matchesCategory;
});
}, [items, query, category]);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<select value={category} onChange={e => setCategory(e.target.value)}>
<option value="all">All</option>
<option value="food">Food</option>
<option value="tech">Tech</option>
</select>
{filtered.length === 0 ? (
<p>No results found</p>
) : (
<ul>
{filtered.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
)}
</div>
);
}동적 컴포넌트
동적 컴포넌트 렌더링은 조회 객체를 사용하여 타입을 컴포넌트에 매핑합니다. 이는 CMS 구동 콘텐츠, 폼 빌더, 페이지 빌더에서 일반적입니다. 컴포넌트 맵은 긴 switch/if 체인을 방지합니다. 항상 알 수 없는 타입을 폴백 컴포넌트로 처리하세요. 모든 속성을 동적 컴포넌트에 전달하기 위해 props를 spread({...block.props})하세요. 이 패턴은 유연하고 확장 가능합니다 - 새 블록 타입을 추가하려면 맵에 추가하기만 하면 됩니다. JSX가 컴포넌트로 처리하도록 변수를 대문자로(Component) 시작하세요.
// Render different components based on type
const componentMap = {
text: TextBlock,
image: ImageBlock,
video: VideoBlock,
quote: QuoteBlock,
};
function ContentRenderer({ blocks }) {
return (
<div>
{blocks.map(block => {
const Component = componentMap[block.type];
if (!Component) return <UnknownBlock key={block.id} type={block.type} />;
return <Component key={block.id} {...block.props} />;
})}
</div>
);
}
// Usage
const blocks = [
{ id: 1, type: 'text', props: { content: 'Hello' } },
{ id: 2, type: 'image', props: { src: 'pic.jpg', alt: 'Picture' } },
];성능 최적화
React.memo
React.memo는 컴포넌트를 감싸서 props가 변경되지 않은 경우 리렌더를 방지합니다(얕은 비교). 같은 props로 자주 렌더되는 컴포넌트에 사용하세요. 두 번째 인수는 사용자 정의 비교 함수입니다: true를 반환하면 리렌더 건너뜀, false를 반환하면 리렌더. memo는 컴포넌트가 렌더 비용이 많이 들거나 자주 렌더되는 부모의 자식인 경우에만 도움이 됩니다. 모든 컴포넌트를 감싸지 마세요 - 메모이제이션은 오버헤드가 있습니다. 최대 효과를 위해 useCallback/useMemo와 결합하세요.
import { memo } from 'react';
// Memoized component: only re-renders if props change
const ExpensiveCard = memo(function ExpensiveCard({ title, content }) {
console.log('Card rendered');
return (
<div className="card">
<h2>{title}</h2>
<p>{content}</p>
</div>
);
});
// Custom comparison function
const MyComponent = memo(function MyComponent(props) {
return <div>{props.value}</div>;
}, (prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return prevProps.value === nextProps.value;
});코드 분할
코드 분할은 컴포넌트를 온디맨드로 로드하여 초기 번들 크기를 줄입니다. React.lazy + Suspense가 동적 import를 가능하게 합니다. fallback prop은 컴포넌트가 로드되는 동안 표시됩니다. 라우트 기반 분할(페이지 컴포넌트를 lazily 로드)이 가장 영향력이 큽니다. 컴포넌트 기반 분할은 즉시 필요하지 않은 무거운 컴포넌트(차트, 에디터)에 유용합니다. 각 lazy import는 별도 chunk를 만듭니다. default export에 React.lazy를 사용하세요; named export의 경우, 모듈로 감싸세요.
import { lazy, Suspense } from 'react';
// Lazy load component (code-split)
const AdminPanel = lazy(() => import('./AdminPanel'));
const Dashboard = lazy(() => import('./Dashboard'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Router>
<Route path="/admin" element={<AdminPanel />} />
<Route path="/dashboard" element={<Dashboard />} />
</Router>
</Suspense>
);
}
// Route-based splitting (most common)
// Component-based splitting
const HeavyChart = lazy(() => import('./HeavyChart'));
function Page({ showChart }) {
return (
<div>
{showChart && (
<Suspense fallback={<Spinner />}>
<HeavyChart />
</Suspense>
)}
</div>
);
}긴 리스트를 위한 Virtualization
Virtualization은 긴 리스트에서 보이는 항목만 렌더하여 성능을 극적으로 향상합니다. react-window와 react-virtualized가 인기 있는 라이브러리입니다. 10,000개의 DOM 노드를 렌더하는 대신, ~12개(보이는 것)만 렌더되며, 스크롤 가능한 컨테이너가 있습니다. 이는 DOM 크기와 렌더 시간을 줄입니다. 100+ 항목의 리스트에 virtualization을 사용하세요. 절충안: 더 복잡한 구현, 검색/찾기 문제(DOM에 없는 항목). 가변 높이 리스트는 VariableSizeList가 필요합니다.
import { FixedSizeList } from 'react-window';
// Virtualized list: only renders visible items
function BigList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
);
return (
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={50}
>
{Row}
</FixedSizeList>
);
}
// Without virtualization: rendering 10,000 items is slow
// With virtualization: only ~12 visible items are renderedDebounce & Throttle
Debounce는 활동 중단까지 실행을 지연합니다(예: 사용자가 입력 중지). Throttle은 간격당 한 번으로 실행을 제한합니다. 둘 다 과도한 API 호출이나 계산을 방지합니다. useDebounce hook은 사용자가 지정된 지연 동안 입력을 중지한 후에만 debounce된 값을 업데이트합니다. 이는 검색 입력, resize 핸들러, scroll 이벤트에 필수적입니다. throttle의 경우, lodash.throttle 같은 라이브러리를 사용하거나 타임스탬프로 구현하세요. useEffect에서 항상 타이머를 cleanup하세요.
import { useState, useEffect } from 'react';
// Debounce hook: delays execution until user stops typing
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
// API call only fires when user stops typing for 300ms
useEffect(() => {
if (debouncedQuery) {
fetch('/api/search?q=' + debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}프로파일링 & 최적화
Profiler 컴포넌트는 렌더 시간을 측정합니다. phase는 'mount' 또는 'update'입니다. actualDuration은 밀리초 단위의 렌더 시간입니다. 시각적 flame chart를 위해 React DevTools Profiler를 사용하세요. 최적화 전에, 실제 병목을 찾기 위해 프로파일하세요 - 추측하지 마세요. 일반적인 성능 문제: (1) 불필요한 리렌더(memo/useMemo/useCallback으로 수정), (2) 비용이 많이 드는 계산(useMemo로 수정), (3) 큰 리스트(virtualization으로 수정), (4) 큰 번들(코드 분할로 수정). 조기 최적화는 시간을 낭비합니다 - 먼저 측정하세요.
import { Profiler } from 'react';
function App() {
const onRender = (id, phase, actualDuration) => {
console.log(id + ' ' + phase + ': ' + actualDuration + 'ms');
};
return (
<Profiler id="App" onRender={onRender}>
<ExpensiveComponent />
</Profiler>
);
}
/* Optimization checklist:
1. React.memo for expensive components
2. useMemo for expensive calculations
3. useCallback for props passed to memoized children
4. Code splitting (lazy) for routes
5. Virtualization for long lists
6. Debounce rapid events (search, resize)
7. Avoid inline objects/functions as props
8. Use keys correctly in lists
9. Profile with React DevTools Profiler
10. Check unnecessary re-renders with why-did-you-render
*/패턴 & 에러 경계
사용자 정의 Hook
사용자 정의 hook은 재사용 가능한 stateful 로직을 'use'로 시작하는 함수로 추출합니다. 다른 hook을 호출할 수 있습니다. 사용자 정의 hook은 컴포넌트 간 로직을 공유하는 기본 방법입니다(HOC와 render props 대체). 여러 값에 대해 객체를 반환하고, 단일 값에 대해 값/배열을 반환하세요. 항상 loading과 error 상태를 처리하세요. 취소 패턴(cancelled flag)은 언마운트 후 state 업데이트를 방지합니다. ESLint 규칙이 작동하도록 'use' 접두사로 hook 이름을 지으세요.
import { useState, useEffect } from 'react';
// Reusable data fetching hook
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(data => { if (!cancelled) { setData(data); setError(null); }})
.catch(err => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Usage
function UserProfile({ id }) {
const { data: user, loading, error } = useFetch('/api/users/' + id);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <h1>{user.name}</h1>;
}useLocalStorage Hook
useLocalStorage는 state를 localStorage에 영속합니다. 지연 초기화는 마운트 시 localStorage에서 읽습니다. useEffect는 값이 변경될 때마다 localStorage에 씁니다. try/catch는 할당량 초과 오류와 JSON 파싱 오류(손상된 데이터)를 처리합니다. 이 패턴은 모든 영속 state에 작동합니다: 테마, 사용자 기본 설정, 초안 콘텐츠. SSR 호환성을 위해, typeof window !== 'undefined'를 확인하세요. 크로스 탭 동기화를 위해, 'storage' 이벤트를 수신하세요. 유사한 hook: useSessionStorage, useCookie.
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error('LocalStorage error:', e);
}
}, [key, value]);
return [value, setValue];
}
// Usage
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Current: {theme}
</button>
);
}에러 경계
에러 경계는 자식 컴포넌트의 render/lifecycle 메서드에서 오류를 잡아 전체 앱이 충돌하는 것을 방지합니다. 클래스 컴포넌트여야 합니다(아직 hook 대응 없음). getDerivedStateFromError가 fallback UI를 표시하도록 state를 업데이트합니다. componentDidCatch는 오류를 로깅합니다(Sentry, LogRocket 등에 전송). 에러 경계가 잡지 않는 것: 이벤트 핸들러, 비동기 코드, setTimeout, 경계 자체의 오류. 특정 섹션을 감싸서 실패를 격리하세요. 'Try again' 버튼이 에러 state를 재설정합니다.
import { Component } from 'react';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('Caught error:', error, errorInfo);
// Send to error reporting service
// logErrorToService(error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div>
<h1>Something went wrong.</h1>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
)
);
}
return this.props.children;
}
}
// Usage: wrap components
<ErrorBoundary fallback={<ErrorPage />}>
<App />
</ErrorBoundary>고차 컴포넌트 (HOC)
고차 컴포넌트(HOC)는 컴포넌트를 받아 향상된 것을 반환하는 함수입니다. hook 이전에 로직 공유를 위한 기본 패턴이었습니다. 일반적 사용: 인증, 로딩 상태, 테마. HOC는 '래퍼 지옥'(깊이 중첩된 컴포넌트)과 prop 충돌을 유발할 수 있습니다. 새 코드의 경우, 사용자 정의 hook을 선호하세요 - 더 간단하고, 더 조합 가능하며, 컴포넌트 트리에 추가하지 않습니다. HOC는 클래스 컴포넌트나 필요로 하는 라이브러리와 통합할 때 여전히 유용합니다.
// HOC: function that takes a component and returns a new one
function withLoading(Component) {
return function WithLoading({ isLoading, ...props }) {
if (isLoading) return <div>Loading...</div>;
return <Component {...props} />;
};
}
// HOC for authentication
function withAuth(Component) {
return function WithAuth(props) {
const { user } = useContext(AuthContext);
if (!user) return <Redirect to="/login" />;
return <Component {...props} user={user} />;
};
}
// Usage
const UserList = withLoading(withAuth(BaseUserList));
// Note: Prefer hooks over HOCs for new code
// HOCs are mainly for class components or library compatibility복합 컴포넌트
복합 컴포넌트를 사용하면 사용자가 단순한 부분에서 복잡한 컴포넌트를 조합할 수 있습니다. 부모(Select)는 context를 제공하고, 자식 컴포넌트(Trigger, Options, Option)는 이를 소비합니다. 이 패턴은 Radix UI, Headless UI, React Aria 같은 라이브러리에서 사용됩니다. 이점: 유연한 API(사용자가 부분을 재정렬/생략 가능), context를 통한 암시적 state 공유, 깔끔한 JSX. 컴포넌트는 정적 속성으로 첨부됩니다(Select.Trigger). 이것은 고급 패턴입니다 - 일회성 컴포넌트가 아닌 재사용 가능한 UI 라이브러리에 사용하세요.
// Compound components: components that work together
function Select({ children, value, onChange }) {
const [isOpen, setIsOpen] = useState(false);
const context = { value, onChange, isOpen, setIsOpen };
return (
<SelectContext.Provider value={context}>
<div className="select">{children}</div>
</SelectContext.Provider>
);
}
Select.Trigger = function Trigger({ children }) {
const { isOpen, setIsOpen } = useContext(SelectContext);
return <button onClick={() => setIsOpen(!isOpen)}>{children}</button>;
};
Select.Options = function Options({ children }) {
const { isOpen } = useContext(SelectContext);
return isOpen ? <div className="options">{children}</div> : null;
};
Select.Option = function Option({ value, children }) {
const { onChange, setIsOpen } = useContext(SelectContext);
return (
<div onClick={() => { onChange(value); setIsOpen(false); }}>
{children}
</div>
);
}
// Usage: clean, declarative API
<Select value={val} onChange={setVal}>
<Select.Trigger>Choose...</Select.Trigger>
<Select.Options>
<Select.Option value="a">Option A</Select.Option>
<Select.Option value="b">Option B</Select.Option>
</Select.Options>
</Select>사용자 정의 Hook
useFetch Hook
사용자 정의 hook은 재사용 가능한 stateful 로직을 'use'로 시작하는 함수로 추출합니다. useFetch는 loading/error 상태와 함께 데이터 가져오기를 캡슐화합니다. AbortController는 컴포넌트가 언마운트되거나 URL이 변경될 때 진행 중인 요청을 취소합니다(경쟁 조건과 메모리 누수 방지). 비동기 작업을 위해 useEffect에 항상 cleanup을 포함하세요. 사용자 정의 hook은 다른 hook(useState, useEffect, useContext)을 호출할 수 있습니다. render props나 HOC 없이 컴포넌트 간 로직을 공유하는 기본 방법입니다. React의 rules-of-hooks linter가 작동하도록 'use' 접두사로 이름을 지으세요.
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let abortController = new AbortController();
setLoading(true);
fetch(url, { ...options, signal: abortController.signal })
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
.then((data) => { setData(data); setError(null); })
.catch((err) => {
if (err.name !== "AbortError") setError(err.message);
})
.finally(() => setLoading(false));
return () => abortController.abort();
}, [url]);
return { data, loading, error };
}
// Usage
function Profile({ userId }) {
const { data, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <div>{data.name}</div>;
}useLocalStorage Hook
useLocalStorage는 React state를 localStorage와 동기화합니다. 지연 초기화는 첫 렌더에서만 localStorage에서 읽습니다. useEffect는 값이 변경될 때마다 localStorage에 씁니다. try/catch는 localStorage가 가득 찼거나 비활성화된(시크릿 브라우징) 경우를 처리합니다. 이 hook은 영속 state를 useState만큼 쉽게 만듭니다. 크로스 탭 동기화를 위해, storage 이벤트 리스너를 추가하세요. SSR 안전을 위해, window 액세스를 보호하세요. 이 패턴은 sessionStorage에도 작동합니다 - API만 교체하세요.
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = window.localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error("LocalStorage write failed:", e);
}
}, [key, value]);
return [value, setValue];
}
// Usage: persists state across page reloads
function Settings() {
const [theme, setTheme] = useLocalStorage("theme", "light");
const [fontSize, setFontSize] = useLocalStorage("fontSize", 14);
return (
<div>
<button onClick={() => setTheme("dark")}>Dark</button>
<button onClick={() => setTheme("light")}>Light</button>
</div>
);
}useDebounce Hook
useDebounce는 사용자가 지정된 지연 동안 입력을 중지할 때까지 값 업데이트를 지연합니다. 이는 검색 입력, 자동 저장, 사용자 입력으로 트리거되는 API 호출에 필수적입니다 - 매 키 입력마다 과도한 호출을 방지합니다. cleanup 함수는 지연이 만료되기 전에 값이 다시 변경되면 타임아웃을 지웁니다. debounce된 값은 일시 중지 후에만 업데이트되어 다운스트림 효과(API 호출 같은)를 덜 빈번하게 트리거합니다. 후행 호출과 함께 즉시 실행하려면 useThrottle을 대신 사용하세요. 효율적인 search-as-you-type을 위해 useFetch와 결합하세요.
function useDebounce(value, delay = 500) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage: debounce search input
function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
fetch(`/api/search?q=${debouncedQuery}`)
.then((res) => res.json())
.then(setResults);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}usePrevious Hook
usePrevious는 useEffect가 렌더 후 실행된다는 사실을 활용합니다 - ref.current는 렌더 중에 여전히 이전 값을 보유하고, 후에 새 값으로 업데이트됩니다. 이는 현재와 이전 state를 비교하는 일반적인 패턴입니다. useWindowSize는 resize 리스너로 viewport 차원을 추적합니다. 메모리 누수를 방지하기 위해 useEffect return에서 항상 이벤트 리스너를 cleanup하세요. 이러한 유틸리티 hook은 사용자 정의 hook이 DOM 관련 로직을 캡슐화하여 컴포넌트를 더 깔끔하게 만들고 로직을 재사용 가능하고 테스트 가능하게 만드는 방법을 보여줍니다.
function usePrevious(value) {
const ref = useRef(null);
useEffect(() => {
ref.current = value; // update AFTER render
}, [value]);
return ref.current; // returns previous value during render
}
// Usage: compare current vs previous
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>Now: {count}, before: {prevCount}</p>
{count > prevCount && <p>Increased!</p>}
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
// useWindowSize hook
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handler = () =>
setSize({ width: innerWidth, height: innerHeight });
window.addEventListener("resize", handler);
return () => removeEventListener("resize", handler);
}, []);
return size;
}useToggle & useClipboard
useToggle은 안정적인 식별자를 위해 useCallback으로 감싼 toggle 함수로 불린 state를 단순화합니다. useClipboard는 timeout 후 자동 재설정되는 'copied' 피드백 state와 함께 clipboard API를 감쌉니다. 이러한 작은 유틸리티 hook은 보일러플레이트를 줄이고 앱 전체에 일반적인 패턴을 표준화합니다. 두 hook 모두의 useCallback은 메모이제이션된 자식의 불필요한 리렌더를 방지합니다. 작고 집중된 hook 라이브러리(useToggle, useClipboard, useMediaQuery, useOnClickOutside)를 구축하면 개발이 가속화되고 일관된 동작이 보장됩니다.
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((p) => !p), []);
return [on, toggle, setOn];
}
function useClipboard(timeout = 2000) {
const [copied, setCopied] = useState(false);
const copy = useCallback((text) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), timeout);
});
}, [timeout]);
return [copied, copy];
}
// Usage
function CopyButton({ text }) {
const [copied, copy] = useClipboard();
return (
<button onClick={() => copy(text)}>
{copied ? "Copied!" : "Copy"}
</button>
);
}
function Modal({ children }) {
const [isOpen, toggle] = useToggle(false);
return (
<>
<button onClick={toggle}>Open</button>
{isOpen && <div className="modal">{children}</div>}
</>
);
}Portals
Portal 만들기
createPortal은 현재 컴포넌트의 계층 외부의 DOM 노드(일반적으로 document.body)로 children을 렌더합니다. 이는 부모 CSS 제약(overflow: hidden, z-index 스태킹 컨텍스트, transform이 새 컨텍스트 생성)을 벗어나야 하는 모달, 툴팁, 드롭다운에 필수적입니다. DOM의 다른 곳에 렌더됨에도 불구하고, portal의 React 이벤트 버블링은 원래 트리에 있는 것처럼 작동합니다 - 조상의 onClick 핸들러가 여전히 실행됩니다. 이것은 두 세계의 장점을 제공합니다: 부모 제약에서의 시각적 탈출, but 논리적 이벤트 흐름 보존.
import { createPortal } from "react-dom";
function Modal({ children, onClose }) {
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose}>✕</button>
{children}
</div>
</div>,
document.body // render target outside the DOM hierarchy
);
}
// Usage: the modal renders at body level,
// escaping any overflow:hidden or z-index stacking contexts
function App() {
const [show, setShow] = useState(false);
return (
<div style={{ overflow: "hidden", position: "relative" }}>
<button onClick={() => setShow(true)}>Open Modal</button>
{show && <Modal onClose={() => setShow(false)}>Hello!</Modal>}
</div>
);
}Portal & Focus Trap이 있는 모달
프로덕션 모달은 portal 그 이상이 필요합니다: focus 관리(모달 내에 trap, 닫을 때 복원), Escape 키 처리, body scroll 잠금, click-outside-to-close. 이 구현은 이전에 포커스된 요소를 저장하고, 열릴 때 모달에 포커스하고, 닫을 때 포커스를 복원합니다 - 스크린 리더 사용자에게 필수적. body overflow hidden은 배경 스크롤을 방지합니다. cleanup 함수가 모든 것을 복원합니다. 완전한 focus trapping(모달 내 tab 순환)을 위해, focus-trap-react 같은 라이브러리를 사용하세요. 닫힐 때 항상 null을 반환하여 DOM에서 제거하세요.
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
const modal = modalRef.current;
const previouslyFocused = document.activeElement;
modal.focus();
const handleKey = (e) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
// Prevent body scroll
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleKey);
document.body.style.overflow = "";
previouslyFocused.focus(); // restore focus
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="overlay" onClick={onClose}>
<div ref={modalRef} tabIndex={-1} className="modal">
{children}
</div>
</div>,
document.body
);
}Portal이 있는 툴팁
툴팁은 부모 컨테이너를 오버플로우하고 잘림을 방지해야 하므로 portal의 이점을 얻습니다. 툴팁 위치는 trigger의 getBoundingClientRect()에서 계산되고 position: fixed로 body 수준에서 렌더됩니다. 이는 z-index와 overflow 문제를 방지합니다. 동적 포지셔닝(화면 가장자리 근처에서 flip)의 경우, Floating UI(이전 Popper.js) 같은 라이브러리를 사용하세요. portal은 툴팁이 overflow: hidden 조상에 의해 잘리지 않도록 보장합니다. fixed 포지셔닝 좌표는 viewport에 상대적이어서 계산이 간단합니다.
function Tooltip({ children, text }) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 });
const targetRef = useRef(null);
const show = () => {
const rect = targetRef.current.getBoundingClientRect();
setCoords({ x: rect.left, y: rect.top - 40 });
setVisible(true);
};
return (
<>
<span
ref={targetRef}
onMouseEnter={show}
onMouseLeave={() => setVisible(false)}
>
{children}
</span>
{visible && createPortal(
<div style={{ position: "fixed", left: coords.x, top: coords.y }}
className="tooltip">
{text}
</div>,
document.body
)}
</>
);
}
// Usage
<Tooltip text="Click to save">💾</Tooltip>Portal이 있는 드롭다운 메뉴
드롭다운 메뉴는 툴팁과 같은 overflow/z-index 문제에 직면합니다. portal이 시각적 문제를 해결합니다. click-outside 핸들러는 클릭 대상이 trigger ref 외부에 있는지 확인합니다. capture-phase scroll 리스너(세 번째 인수로 true)는 모든 scroll 시 메뉴를 닫아, 메뉴가 trigger에서 분리되는 것을 방지합니다. 프로덕션의 경우, 가장자리 감지, flip, shift, scroll/resize 시 자동 포지셔닝 업데이트를 처리하는 Floating UI를 사용하세요. portal + 적절한 포지셔닝 로직 = 모든 레이아웃 컨텍스트에서 작동하는 강력한 드롭다운.
function Dropdown({ trigger, children }) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0 });
const ref = useRef(null);
const handleOpen = () => {
const rect = ref.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left });
setOpen(true);
};
useEffect(() => {
if (!open) return;
const handleClick = (e) => {
if (!ref.current?.contains(e.target)) setOpen(false);
};
const handleScroll = () => setOpen(false); // close on scroll
document.addEventListener("mousedown", handleClick);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleClick);
window.removeEventListener("scroll", handleScroll, true);
};
}, [open]);
return (
<>
<div ref={ref} onClick={handleOpen}>{trigger}</div>
{open && createPortal(
<div style={{ position: "fixed", ...pos }} className="dropdown">
{children}
</div>,
document.body
)}
</>
);
}Portal 이벤트 버블링
React Portal의 핵심 기능: 이벤트 버블링은 DOM 트리가 아닌 React 컴포넌트 트리를 따릅니다. 자식이 document.body로 portaled되어도 부모 컴포넌트의 onClick이 실행됩니다. 이는 context, state, 이벤트 위임이 모두 자연스럽게 작동함을 의미합니다. 하지만 CSS 상속은 portal 경계를 넘지 않습니다 - 부모의 스타일이 다른 DOM 하위 트리에 있으므로 portaled된 콘텐츠로 캐스케이드되지 않습니다. portal 콘텐츠를 스타일링하려면 명시적으로 CSS를 적용하세요(클래스나 :root의 CSS 변수 통해). 이 분리는 모달/툴팁에 일반적으로 바람직합니다.
function PortalExample() {
// Despite rendering in document.body, events bubble
// through the React tree, not the DOM tree
return (
<div onClick={() => console.log("Parent clicked!")}>
<p>Click the button — parent handler fires!</p>
{createPortal(
<button onClick={() => console.log("Button clicked!")}>
I'm in a portal
</button>,
document.body
)}
</div>
);
}
// Clicking logs: "Button clicked!" then "Parent clicked!"
// This means context, state, and event delegation
// all work as if the portal were inline
// But CSS inheritance does NOT cross the portal boundary:
// document.body styles won't inherit into the portal content
// unless you explicitly apply themSuspense & 지연 로딩
React.lazy & Suspense
React.lazy는 컴포넌트를 동적으로 import하여 온디맨드로 로드되는 별도 번들을 만듭니다(코드 분할). lazy 컴포넌트를 chunk가 다운로드되는 동안 표시되는 fallback(로딩 상태)과 함께 <Suspense>로 감싸세요. 이는 초기 번들 크기를 줄입니다 - 사용자는 방문하는 페이지의 코드만 다운로드합니다. 각 lazy() 호출은 별도 chunk를 만듭니다. 라우트 기반 분할의 경우, 각 페이지 컴포넌트를 lazy 로드하세요. fallback은 모든 React 노드(spinner, skeleton, text)가 될 수 있습니다. Suspense는 여러 lazy 컴포넌트를 감쌀 수 있습니다 - fallback은 모두 준비될 때까지 표시됩니다.
import { lazy, Suspense } from "react";
// Lazy-load component (code-split)
const Dashboard = lazy(() => import("./Dashboard"));
const Settings = lazy(() => import("./Settings"));
function App() {
return (
<Suspense fallback={<div>Loading page...</div>}>
<nav>
<button onClick={() => setPage("dash")}>Dashboard</button>
<button onClick={() => setPage("settings")}>Settings</button>
</nav>
{page === "dash" && <Dashboard />}
{page === "settings" && <Settings />}
</Suspense>
);
}중첩 Suspense
중첩 Suspense 경계는 각 chunk가 로드됨에 따라 콘텐츠가 점진적으로 공개되는 '껍질 벗기기' 효과를 만듭니다. 외부 Suspense가 먼저 fallback을 표시하고, 내부 컴포넌트가 로드됨에 따라 독립적으로 공개됩니다. 이는 단일 느린 컴포넌트가 전체 페이지를 차단하는 것을 방지합니다. Suspense 경계를 전략적으로 배치하세요: 라우트 수준 페이지 주위(거칠게), 주요 섹션 주위(중간), 독립 위젯 주위(세밀하게). 너무 많은 경계는 불안정한 로딩을 만들고, 너무 적은 경계는 긴 대기를 만듭니다. 핵심은 경계를 사용자가 인지하는 콘텐츠 단위에 일치시키는 것입니다.
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<MainSkeleton />}>
<MainContent /> {/* loads first */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* loads independently, doesn't block MainContent */}
</Suspense>
</Suspense>
<Sidebar />
</Suspense>
// Suspense "peeling" effect:
// 1. PageSkeleton shows
// 2. Header + Sidebar load → PageSkeleton peels away
// 3. MainSkeleton shows until MainContent loads
// 4. MainContent shows, CommentsSkeleton shows
// 5. Comments load → everything visible에러 경계와 Lazy
지연 로딩은 실패할 수 있습니다(네트워크 문제, 배포가 chunk URL을 무효화). 에러 경계는 이러한 오류를 잡고 fallback UI를 표시합니다. 항상 Suspense + lazy를 ErrorBoundary로 감싸세요. componentDidCatch가 모니터링을 위해 오류를 로깅합니다. 재시도 로직의 경우, ErrorBoundary의 state를 재설정하거나 페이지를 다시 로드할 수 있습니다. 일반적인 패턴은 chunk를 다시 import하는 재시도 버튼입니다. 에러 경계 없이, 실패한 chunk 로드는 전체 앱을 충돌시킵니다. 이것은 프로덕션에 중요합니다 - 네트워크 신뢰성은 결코 100%가 아닙니다.
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
console.error("Chunk load failed:", error, info);
}
render() {
if (this.state.hasError) {
return (
<div>
<p>Failed to load. {this.state.error.message}</p>
<button onClick={() => window.location.reload()}>
Retry
</button>
</div>
);
}
return this.props.children;
}
}
// Wrap lazy components: network failures need handling
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<LazyComponent />
</Suspense>
</ErrorBoundary>Suspense로 데이터 가져오기
React 19의 use() hook은 데이터 가져오기를 위한 Suspense를 가능하게 합니다. useEffect와 달리, use()는 promise가 해결될 때까지 컴포넌트를 일시 중단합니다 - 가장 가까운 Suspense 경계가 fallback을 표시합니다. 같은 컴포넌트의 여러 use() 호출은 동시에 해결됩니다(병렬 가져오기). 이는 수동 로딩 state 관리를 제거합니다. promise는 리렌더 시 다시 가져오기를 방지하기 위해 React 외부에서 캐시될 수 있습니다. 참고: use()는 render나 hook 내부에서만 호출될 수 있습니다. React 18의 경우, Suspense와 통합되는 React Query나 SWR를 사용하세요.
// React 18+ Suspense for data fetching (experimental)
import { use } from "react"; // React 19+
// Wrap a promise with use()
function UserProfile({ userId }) {
// 'use' suspends until the promise resolves
const user = use(fetchUser(userId));
return <div>{user.name}</div>;
}
function fetchUser(id) {
return fetch(`/api/users/${id}`).then((r) => r.json());
}
// Parent provides Suspense boundary
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile userId={1} />
</Suspense>
);
}
// Concurrent: multiple suspends resolve together
function Dashboard() {
const user = use(fetchUser(1));
const posts = use(fetchPosts(user.id));
// Both fetch in parallel, Suspense shows until all resolve
return <div>{user.name}: {posts.length} posts</div>;
}Suspense List (오케스트레이션)
SuspenseList는 여러 Suspense 경계의 공개 순서를 오케스트레이션합니다. revealOrder='forwards'는 순서대로 항목을 표시합니다(2가 먼저 로드되더라도 1이 준비될 때까지 항목 2는 공개되지 않음) - 콘텐츠 점프를 방지합니다. 'together'는 모두 공개 전에 대기합니다. 'backwards'는 아래에서 위로 공개합니다. tail='collapsed'는 아직 시작되지 않은 항목의 로딩 state를 숨깁니다; 'hidden'은 모든 fallback을 숨깁니다. 이는 순서가 중요한 피드와 리스트에 유용합니다. 참고: SuspenseList는 실험적이었으며 API가 변경될 수 있습니다 - 가용성은 현재 React 문서를 확인하세요.
// SuspenseList controls reveal order of multiple Suspense
import { SuspenseList, Suspense } from "react";
function Article({ id }) {
const data = use(fetchArticle(id));
return <article>{data.title}</article>;
}
function Feed() {
return (
<SuspenseList revealOrder="forwards" tail="collapsed">
{/* "forwards": reveals top-to-bottom as they load */}
{/* "together": waits for all, reveals together */}
{/* "backwards": reveals bottom-to-top */}
<Suspense fallback={<Skeleton />}>
<Article id={1} />
</Suspense>
<Suspense fallback={<Skeleton />}>
<Article id={2} />
</Suspense>
<Suspense fallback={<Skeleton />}>
<Article id={3} />
</Suspense>
</SuspenseList>
);
}React Router
기본 라우팅
React Router v6는 root로 <BrowserRouter>를, 라우트 매칭을 정의하기 위해 <Routes>를, element prop( component가 아님)과 함께 <Route>를 사용합니다. <Link>는 History API를 사용하는 내비게이션 링크를 만듭니다(페이지 새로고침 없음). 동적 세그먼트(:id)는 useParams()로 액세스합니다. path='*'은 404를 위한 catch-all입니다. 라우트는 순서가 아닌 최적 매칭으로 매칭됩니다. URL 검색 매개변수(?q=search)의 경우 useSearchParams()를 사용하세요. BrowserRouter는 모든 라우트에 대해 index.html을 제공하기 위한 서버 구성이 필요합니다(SPA 폴백).
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/users/123">User 123</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/users/:id" element={<UserPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
function UserPage() {
const { id } = useParams();
return <h1>User ID: {id}</h1>;
}중첩 라우트 & Outlet
중첩 라우트는 레이아웃 계층을 만듭니다. 부모 라우트의 element는 자식 라우트가 렌더되는 <Outlet />을 포함해야 합니다. index 라우트는 부모의 경로에서 렌더됩니다. 깊게 중첩된 라우트(users/:id)는 중첩된 레이아웃을 만듭니다 - Users 레이아웃이 UserDetail을 감쌉니다. 이는 지속적인 사이드바/헤더가 있는 대시보드에 강력합니다. useOutlet()이 자식 요소에 액세스를 제공합니다. URL /users/123은 Layout → Users → UserDetail을 렌더하며, 각각이 레이아웃에 기여합니다. 이것은 레이아웃의 수동 조건부 렌더링을 대체합니다.
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="users" element={<Users />}>
<Route path=":id" element={<UserDetail />} />
</Route>
</Route>
</Routes>
</BrowserRouter>
);
}
function Layout() {
return (
<div>
<nav>Navigation here</nav>
<Outlet /> {/* Child routes render here */}
</div>
);
}
function Users() {
return (
<div>
<h2>Users</h2>
<Outlet /> {/* Nested :id route renders here */}
</div>
);
}내비게이션 & 리다이렉트
useNavigate는 프로그래밍 방식 내비게이션을 위한 함수를 반환합니다. navigate('/path', { replace: true })가 history를 대체합니다(뒤로 버튼 없음). 다음 라우트로 데이터를 전달하기 위해 state를 전달하세요(예: 로그인 후 돌아갈 곳). <Navigate>는 선언적 리다이렉트 컴포넌트입니다 - render에서 auth 가드를 위해 사용하세요. NavLink는 활성 링크 스타일링을 위해 isActive를 제공합니다. useLocation이 현재 URL, pathname, search, hash, state를 제공합니다. 작업(폼 제출) 후 리다이렉트의 경우 navigate를 사용하세요. render에서 조건부 리다이렉트의 경우 <Navigate>를 사용하세요.
import { useNavigate, Navigate, NavLink, useLocation } from "react-router-dom";
function Login() {
const navigate = useNavigate();
const location = useLocation();
const handleLogin = async () => {
await auth.login();
// Redirect to intended page or home
const from = location.state?.from || "/";
navigate(from, { replace: true });
};
return <button onClick={handleLogin}>Login</button>;
}
// Declarative redirect
function ProtectedRoute({ user, children }) {
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
// NavLink: active styling
<NavLink to="/about" className={({ isActive }) =>
isActive ? "nav-active" : "nav"
}>
About
</NavLink>Loader & 데이터 로딩
React Router v6.4+(data router)는 loader(라우트 렌더 전 실행)와 action(폼 제출 처리)을 추가합니다. useLoaderData()가 loader 데이터에 액세스합니다 - 라우트 데이터 가져오기를 위한 useEffect가 더 이상 필요 없음. loader는 중첩된 라우트에 대해 병렬로 실행됩니다. errorElement가 loader/action에서 오류를 잡습니다. action은 <Form method='post'>를 통해 폼 제출을 처리합니다 - useActionData()가 결과를 반환합니다. 이 패턴(Remix에서 영감)은 데이터 로직을 라우트와 공동 배치합니다. data API는 <BrowserRouter>가 아닌 createBrowserRouter/createHashRouter가 필요합니다.
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/users/:id",
element: <UserPage />,
loader: async ({ params }) => {
const res = await fetch(`/api/users/${params.id}`);
if (!res.ok) throw new Response("Not found", { status: 404 });
return res.json();
},
errorElement: <ErrorPage />,
},
]);
function UserPage() {
const user = useLoaderData(); // data from loader
return <h1>{user.name}</h1>;
}
// Action for form submissions
{
path: "/users/new",
element: <NewUser />,
action: async ({ request }) => {
const formData = await request.formData();
const res = await fetch("/api/users", {
method: "POST",
body: formData,
});
return redirect(`/users/${res.id}`);
},
}
function App() {
return <RouterProvider router={router} />;
}라우트 가드 & 보호된 라우트
라우트 가드는 auth state나 역할을 기반으로 라우트를 보호합니다. RequireAuth는 인증되지 않은 사용자를 로그인으로 리다이렉트하고, 로그인 후 리다이렉트를 위해 의도된 목적지를 location.state에 보존합니다. RequireRole은 역할 기반 액세스 제어를 추가합니다. 가드를 감싸서 조합하세요(RequireAuth > RequireRole > component). 레이아웃 라우트의 경우 element={<RequireAuth><Outlet/></RequireAuth>}를 사용하여 모든 자식 라우트를 한 번에 보호할 수도 있습니다. 항상 서버에서도 auth를 확인하세요 - 클라이언트 측 가드는 UX용, 보안용이 아닙니다. 이 패턴은 모든 조건으로 확장됩니다: 구독, 기능 플래그 등.
function RequireAuth({ children }) {
const { user } = useAuth();
const location = useLocation();
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="dashboard" element={
<RequireAuth><Dashboard /></RequireAuth>
} />
<Route path="admin" element={
<RequireAuth><RequireRole role="admin"><Admin /></RequireRole></RequireAuth>
} />
</Route>
</Routes>
</BrowserRouter>
);
}
function RequireRole({ role, children }) {
const { user } = useAuth();
if (user?.role !== role) return <Navigate to="/forbidden" />;
return children;
}상태 관리 (Context & Redux)
Context API 패턴
Context는 prop drilling 없이 전역 state를 제공합니다. context를 만들고, 소비자를 Provider로 감싸고, useContext로 액세스하세요. 사용자 정의 useAuth hook은 오류 검사를 추가하고 권장되는 API 표면입니다. Context는 저빈도 업데이트(auth, theme, locale)에 이상적입니다. 고빈도 state 변경의 경우, Context는 모든 소비자가 매 변경마다 리렌더되게 합니다 - 복잡한 state에는 useReducer를 사용하거나 context를 분할하세요. 항상 provider를 관리하는 state와 공동 배치하세요. Context 값은 함수를 포함하는 경우 useMemo/useCallback으로 메모이제이션해야 합니다.
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = async (credentials) => {
const user = await api.login(credentials);
setUser(user);
};
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be inside AuthProvider");
return ctx;
}
// Usage
function Navbar() {
const { user, logout } = useAuth();
return user
? <button onClick={logout}>Logout {user.name}</button>
: <Link to="/login">Login</Link>;
}
// Wrap app: <AuthProvider><App /></AuthProvider>useReducer + Context
useReducer + Context는 외부 라이브러리 없이 복잡한 전역 state를 위한 권장 패턴입니다. reducer는 state 로직을 중앙 집중화합니다(예측 가능하고, 테스트 가능한 전환). provider는 불필요한 리렌더를 방지하기 위해 값을 메모이제이션합니다. 파생 값(total)은 useMemo에서 계산됩니다. 이 패턴은 cart, 폼 state, 다단계 마법사 등을 처리합니다. 미들웨어, time-travel 디버깅, 또는 많은 독립적인 slice가 있는 진정으로 복잡한 앱의 경우, Redux Toolkit이나 Zustand를 고려하세요. 하지만 대부분의 앱에서 useReducer + Context는 충분하며 종속성이 없습니다.
const CartContext = createContext();
function cartReducer(state, action) {
switch (action.type) {
case "ADD":
const existing = state.find((i) => i.id === action.item.id);
if (existing) {
return state.map((i) =>
i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i
);
}
return [...state, { ...action.item, qty: 1 }];
case "REMOVE":
return state.filter((i) => i.id !== action.id);
case "CLEAR":
return [];
default:
return state;
}
}
function CartProvider({ children }) {
const [items, dispatch] = useReducer(cartReducer, []);
const value = useMemo(() => ({
items,
total: items.reduce((s, i) => s + i.price * i.qty, 0),
addItem: (item) => dispatch({ type: "ADD", item }),
removeItem: (id) => dispatch({ type: "REMOVE", id }),
}), [items]);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}Redux Toolkit 기본
Redux Toolkit(RTK)은 Redux를 사용하는 최신 권장 방법입니다. createSlice가 action creator와 reducer를 자동 생성합니다. Immer를 내부적으로 사용하므로, state를 직접 '변경'하고(state.value += 1) Immer가 불변 업데이트를 생성합니다. configureStore가 합리적인 기본값으로 store를 설정합니다(Redux DevTools, thunk 미들웨어). useSelector가 state를 읽고, useDispatch가 action을 dispatch합니다. RTK는 Redux 보일러플레이트를 제거합니다(switch 문 없음, action type 상수 없음). 비동기 로직의 경우 createAsyncThunk를 사용하세요. RTK Query(포함)가 데이터 가져오기와 캐싱을 처리합니다.
import { configureStore, createSlice } from "@reduxjs/toolkit";
import { useSelector, useDispatch } from "react-redux";
const counterSlice = createSlice({
name: "counter",
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1; }, // Immer: mutate safely
decrement: (state) => { state.value -= 1; },
addBy: (state, action) => { state.value += action.payload; },
},
});
const store = configureStore({
reducer: { counter: counterSlice.reducer },
});
export const { increment, decrement, addBy } = counterSlice.actions;
// Usage in component
function Counter() {
const count = useSelector((state) => state.counter.value);
const dispatch = useDispatch();
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(addBy(5))}>+5</button>
</div>
);
}Zustand (가벼운 대안)
Zustand는 최소 상태 관리 라이브러리입니다 - provider 없음, 보일러플레이트 없음. create()로 store를 만들고, selector 함수로 hook을 통해 액세스하세요. selector는 리렌더를 방지합니다: 변경된 slice를 사용하는 컴포넌트만 리렌더합니다. 이는 Redux의 복잡성 없이 Context의 리렌더 문제를 해결합니다. 객체 selector({a, b} 반환)의 경우, 불필요한 리렌더를 방지하기 위해 얕은 비교를 사용하세요. Zustand는 미들웨어(persist, devtools, immer)를 지원합니다. Redux가 과잉이지만 Context가 너무 많은 리렌더를 유발하는 소규모-중간 규모 앱에 이상적입니다. API는 작지만 강력합니다.
import { create } from "zustand";
const useStore = create((set, get) => ({
count: 0,
user: null,
increment: () => set((state) => ({ count: state.count + 1 })),
setUser: (user) => set({ user }),
reset: () => set({ count: 0, user: null }),
// Access other state with get()
doubleCount: () => get().count * 2,
}));
// Usage: select only what you need (prevents re-renders)
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
// Multiple selections
function Profile() {
const { user, setUser } = useStore(
(state) => ({ user: state.user, setUser: state.setUser })
);
// Use shallow comparison for object selectors
// import { shallow } from "zustand/shallow";
// useStore(selector, shallow);
}React Query (서버 상태)
React Query(TanStack Query)는 API에서 가져온 서버 state를 관리합니다. 캐싱, 백그라운드 다시 가져오기, 오래된 데이터, 낙관적 업데이트, 페이지네이션을 자동으로 처리합니다. queryKey가 캐시된 데이터를 식별합니다(cache key 같은). staleTime은 데이터가 신선한 것으로 간주되는 기간을 제어합니다. mutation 후 invalidateQueries가 종속 쿼리를 다시 가져옵니다. Redux(클라이언트 state)와 달리, React Query는 비동기 서버 데이터를 위해 목적에 맞게 구축되었습니다. 수동 로딩/error state, useEffect 가져오기, 캐시 관리를 제거합니다. 대부분의 앱에서, React Query + 로컬 state(useState/useReducer)가 Redux를 완전히 대체합니다.
import { useQuery, useMutation, QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Users />
</QueryClientProvider>
);
}
function Users() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["users"],
queryFn: () => fetch("/api/users").then((r) => r.json()),
staleTime: 60000, // fresh for 60s
refetchOnWindowFocus: true,
});
const mutation = useMutation({
mutationFn: (newUser) =>
fetch("/api/users", { method: "POST", body: JSON.stringify(newUser) }),
onSuccess: () => queryClient.invalidateQueries(["users"]),
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
{data.map((u) => <div key={u.id}>{u.name}</div>)}
<button onClick={() => mutation.mutate({ name: "New" })}>Add</button>
</div>
);
}테스팅 (React Testing Library)
기본 컴포넌트 테스트
React Testing Library(RTL)는 컴포넌트를 사용자가 상호작용하는 방식대로 테스트합니다 - 구현 세부사항이 아닌 role, label, text로. getByRole이 선호되는 조회입니다(접근성도 테스트). userEvent는 fireEvent보다 실제 사용자 상호작용(입력, 클릭)을 더 정확하게 시뮬레이션합니다. 테스트는 내부 state 테스트를 피해야 합니다; 대신 보이는 출력과 동작을 검증하세요. role로 조회할 수 없는 경우, getByLabelText, getByText, 또는 getByDisplayValue를 사용하세요. 필요한 경우에만 getByTestId를 사용하세요. 이 접근 방식은 테스트를 리팩토링에 강하게 만듭니다 - 사용자가 보고 하는 것을 테스트합니다.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";
test("counter increments on click", async () => {
const user = userEvent.setup();
render(<Counter />);
// Find by accessible role (not test-id)
expect(screen.getByRole("heading")).toHaveTextContent("0");
await user.click(screen.getByRole("button", { name: /increment/i }));
expect(screen.getByRole("heading")).toHaveTextContent("1");
});
test("displays error for invalid input", async () => {
const user = userEvent.setup();
render(<Form />);
await user.type(screen.getByLabelText(/email/i), "not-an-email");
await user.click(screen.getByRole("button", { name: /submit/i }));
expect(screen.getByRole("alert")).toHaveTextContent(/invalid email/i);
});Hook 테스팅
renderHook는 사용자 정의 hook을 격리하여 테스트합니다. result.current가 hook의 반환 값을 보유합니다. 모든 state 업데이트는 React가 동기적으로 처리하도록 act()로 감싸야 합니다. rerender로 변경되는 prop에 의존하는 effect를 테스트할 수 있습니다. 비동기 hook(fetch가 있는 useEffect)의 경우, waitFor나 findBy 조회(업데이트 대기)를 사용하세요. hook을 직접 테스트하는 것이 컴포넌트를 통해 테스트하는 것보다 빠르고 집중됩니다. 하지만, 실제 사용을 검증하기 위해 컴포넌트 통합 테스트를 통해 hook을 테스트하세요. renderHook는 @testing-library/react v13+에서 사용 가능합니다.
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";
test("useCounter increments and decrements", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
// Wrap state updates in act()
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
act(() => result.current.reset());
expect(result.current.count).toBe(0);
});
// Testing with initial props that change
test("useEffect runs on dependency change", () => {
const { result, rerender } = renderHook(
({ id }) => useFetchUser(id),
{ initialProps: { id: 1 } }
);
rerender({ id: 2 });
// Effect re-ran with new id
});비동기 테스팅 & 모킹
MSW(Mock Service Worker)는 service worker 수준에서 네트워크 요청을 가로챕니다 - 테스트는 실제 fetch()를 사용하지만 모킹된 응답을 받습니다. 이는 fetch를 직접 모킹하는 것보다 더 현실적입니다. Node용 setupServer(Jest), 브라우저용 setupWorker. beforeAll/afterAll lifecycle이 서버를 관리합니다. server.use()로 테스트별로 핸들러를 재정의합니다. findBy 조회(비동기)는 요소가 나타날 때까지 대기합니다 - 비동기 렌더링에 사용. queryBy는 찾지 못한 경우 null을 반환합니다(부재 확인용). waitFor가 조건을 폴링합니다. MSW는 개발 모킹과 Storybook에도 사용할 수 있습니다.
import { render, screen, waitFor } from "@testing-library/react";
import { rest } from "msw";
import { setupServer } from "msw/node";
import { UserProfile } from "./UserProfile";
// Mock API with MSW (Mock Service Worker)
const server = setupServer(
rest.get("/api/users/:id", (req, res, ctx) => {
return res(ctx.json({ id: 1, name: "Alice" }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("displays user after fetch", async () => {
render(<UserProfile id={1} />);
// findBy waits for async update
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(screen.queryByText("Loading")).not.toBeInTheDocument();
});
test("shows error on fetch failure", async () => {
server.use(
rest.get("/api/users/:id", (req, res, ctx) =>
res(ctx.status(500))
)
);
render(<UserProfile id={1} />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});Context & Provider 테스팅
필요한 provider(Theme, Auth, Router 등)로 컴포넌트를 감싸는 사용자 정의 render 유틸리티를 만드세요. 이는 모든 테스트에서 provider 설정 반복을 방지합니다. RTL 함수를 test-utils 파일에서 재내보내어 테스트가 거기에서 import하게 하세요. router 테스팅의 경우, 실제 브라우저 history가 필요 없이 초기 URL을 설정하기 위해 initialEntries와 함께 MemoryRouter(BrowserRouter가 아닌)를 사용하세요. Redux의 경우, 실제나 모의 store와 함께 test Provider로 감싸세요. 이 패턴은 테스트를 깔끔하게 유지하고 모든 컴포넌트가 필요한 context를 갖도록 보장합니다. 모든 React 테스팅 인프라의 표준 설정입니다.
// Custom render that wraps with providers
import { render } from "@testing-library/react";
import { ThemeProvider } from "./ThemeProvider";
function customRender(ui, { theme = "light", ...options } = {}) {
function Wrapper({ children }) {
return <ThemeProvider initialTheme={theme}>{children}</ThemeProvider>;
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Re-export everything
export * from "@testing-library/react";
export { customRender as render };
// In test files, import from your test-utils:
// import { render, screen } from "../test-utils";
test("button uses theme color", () => {
customRender(<Button>Click</Button>, { theme: "dark" });
expect(screen.getByRole("button")).toHaveClass("btn-dark");
});
// Testing with router
import { MemoryRouter } from "react-router-dom";
render(
<MemoryRouter initialEntries={["/users/123"]}>
<App />
</MemoryRouter>
);이벤트 & 상호작용 테스팅
userEvent.setup()는 현실적인 상호작용을 위한 사용자 인스턴스를 만듭니다: type(문자별), click, tab, keyboard({Escape}, {Enter} 같은 키 코드와 함께), selectOptions, upload 등. 사용자 상호작용은 항상 await하세요 - 비동기입니다. 접근성을 위해 키보드 내비게이션 테스팅이 중요합니다. 폼 테스팅의 경우, 모든 필드를 채우고 onSubmit이 올바른 데이터를 받는지 검증하세요. userEvent는 실제 브라우저 동작(올바른 순서의 focus, blur, input 이벤트)을 시뮬레이션하므로 fireEvent보다 선호됩니다. 개별 이벤트 핸들러가 아닌 전체 사용자 흐름을 테스트하세요.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("form submission flow", async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
// Fill form fields
await user.type(screen.getByLabelText(/email/i), "[email protected]");
await user.type(screen.getByLabelText(/password/i), "password123");
// Submit
await user.click(screen.getByRole("button", { name: /login/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: "[email protected]",
password: "password123",
});
});
test("keyboard navigation", async () => {
const user = userEvent.setup();
render(<Modal />);
await user.tab(); // focus first element
expect(screen.getByRole("button", { name: /close/i })).toHaveFocus();
await user.keyboard("{Escape}"); // press Escape
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});TypeScript + React
컴포넌트 Props 타이핑
TypeScript와 React는 prop에 대한 타입 안전성을 제공합니다. prop에 대해 interface나 type 별칭을 사용하세요. 선택적 prop은 ?를 사용합니다. union 타입(variant)이 값을 제한합니다. React.ReactNode는 렌더 가능한 콘텐츠(문자열, 요소, 배열)를 받습니다. HTML 속성 확장(React.InputHTMLAttributes)으로 컴포넌트가 모든 네이티브 속성(placeholder, onChange 등)을 받으면서 사용자 정의 prop을 추가할 수 있습니다. spread {...rest}가 남은 속성을 네이티브 요소로 전달합니다. 이 패턴은 타입 안전하고 유연한 컴포넌트를 만듭니다. 소비자가 참조할 수 있도록 prop 타입을 항상 export하세요.
// Basic props
interface ButtonProps {
text: string;
onClick: () => void;
variant?: "primary" | "secondary"; // union type
disabled?: boolean;
}
function Button({ text, onClick, variant = "primary", disabled }: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled}
>
{text}
</button>
);
}
// Children prop
interface CardProps {
title: string;
children: React.ReactNode; // any renderable content
}
// Extending HTML attributes
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
function Input({ label, error, ...rest }: InputProps) {
return (
<label>
{label}
<input {...rest} />
{error && <span className="error">{error}</span>}
</label>
);
}TypeScript와 Hook
TypeScript는 hook에 타입 안전성을 추가합니다. useState<T>가 state 타입을 지정하고, nullable state에 useState<T | null>(null). useRef<T>(null)이 ref를 타이핑합니다 - current는 T | null. useContext의 경우, context 타입을 정의하고 undefined인 경우 throw하세요(소비자가 non-undefined 타입을 얻도록). useReducer의 경우, Action을 discriminated union으로 타이핑하세요 - action.type에 대한 switch가 각 케이스에서 타입을 좁혀 타입 안전한 payload 액세스를 제공합니다. 이 패턴은 undefined 액세스와 잘못된 action payload로 인한 런타임 오류를 제거합니다.
// useState with types
const [count, setCount] = useState<number>(0);
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<string[]>([]);
// useRef
const inputRef = useRef<HTMLInputElement>(null);
// Access: inputRef.current?.focus()
// useContext
interface ThemeContextType {
theme: "light" | "dark";
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be inside ThemeProvider");
return ctx; // ctx is now ThemeContextType, not undefined
}
// useReducer
type Action = { type: "increment" } | { type: "set"; value: number };
const [state, dispatch] = useReducer((state: number, action: Action) => {
switch (action.type) {
case "increment": return state + 1;
case "set": return action.value;
}
}, 0);제네릭 컴포넌트
제네릭 컴포넌트와 hook은 타입 안전성을 유지하면서 모든 데이터 타입과 작동합니다. <T> 타입 매개변수는 items prop에서 추론되므로, renderItem과 keyExtractor가 자동으로 올바른 타입을 받습니다. 이것이 TypeScript가 완전한 타입 안전성으로 제네릭 유틸리티 컴포넌트(List, Table, Select)를 재생성하는 방법입니다. 제네릭 hook(useArray<T>)도 작업을 통해 타입을 보존합니다. 핵심 통찰: TypeScript가 사용에서 T를 추론하므로, 소비자는 거의 명시적으로 지정할 필요가 없습니다. 이 패턴은 재사용 가능하고 타입 안전한 컴포넌트 라이브러리를 구축하는 데 필수적입니다.
// Generic component: works with any type
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage: TypeScript infers T from items
<List
items={[{ id: "1", name: "Alice" }, { id: "2", name: "Bob" }]}
renderItem={(user) => <span>{user.name}</span>}
keyExtractor={(user) => user.id}
/>
// Generic hook
function useArray<T>(initial: T[]) {
const [array, setArray] = useState(initial);
const push = (item: T) => setArray((prev) => [...prev, item]);
return { array, push, setArray };
}이벤트 타입 & Ref
React 이벤트 타입은 특정합니다: 입력용 ChangeEvent, 폼용 FormEvent, 클릭용 MouseEvent. 각각 요소 타입에 대해 제네릭입니다(e.target이 올바르게 타이핑됨). TypeScript와 forwardRef는 두 개의 타입 매개변수가 필요합니다: ref 타입과 props 타입. forwardRef는 컴포넌트가 DOM 요소에 ref를 노출해야 할 때 필요합니다(focus, 측정 등). 더 나은 DevTools 디버깅을 위해 forwardRef/memo 컴포넌트에 항상 displayName을 설정하세요. React 19는 ref를 일반 prop으로 허용하여 forwardRef의 필요성을 줄이지만, 기존 코드에서는 여전히 일반적입니다.
// Event types
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
console.log(e.target.value); // string
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
}
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
console.log(e.clientX, e.clientY);
}
// forwardRef with TypeScript
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, ...props }, ref) => (
<label>
{label}
<input ref={ref} {...props} />
</label>
)
);
Input.displayName = "Input";
// Usage: const ref = useRef<HTMLInputElement>(null);
// <Input ref={ref} label="Email" />Props를 위한 유틸리티 타입
TypeScript 유틸리티 타입은 prop 조합에 강력합니다. Pick은 특정 prop을 선택합니다(하위 집합 컴포넌트용). Omit은 prop을 제외합니다(동작 교체용). Partial은 모든 prop을 선택적으로 만듭니다(기본 prop 패턴용). ComponentProps<typeof Component>가 컴포넌트의 prop 타입을 추출합니다 - 기존 컴포넌트 감싸기/확장에 유용. Record<K, V>가 키를 값에 매핑하는 타입을 만듭니다(variant-to-class 맵에 적합). 이 유틸리티는 interface를 반복하지 않고 DRY하고 타입 안전한 prop 정의를 가능하게 합니다. 유지보수 가능한 React + TypeScript 코드를 작성하려면 마스터하세요.
// Pick: select specific props
interface ButtonProps {
text: string;
onClick: () => void;
color: string;
size: "sm" | "md" | "lg";
}
type IconButtonProps = Pick<ButtonProps, "onClick" | "size"> & {
icon: React.ReactNode;
};
// Omit: exclude specific props
type LinkButtonProps = Omit<ButtonProps, "onClick"> & {
href: string;
};
// Partial: all props optional (for defaults)
type DefaultProps = Partial<ButtonProps>;
// ComponentProps: extract props from existing component
type MyButtonProps = React.ComponentProps<typeof Button> & {
variant?: "custom";
};
// ReturnType: type of a function's return
type User = ReturnType<typeof fetchUser>;
// Record for prop maps
type ButtonVariants = Record<"primary" | "danger" | "ghost", string>;Concurrent 기능 (useTransition, useDeferredValue)
useTransition
useTransition은 state 업데이트를 비긴급(transition)으로 표시합니다. 긴급 업데이트(입력 값)는 응답성을 위해 즉시 렌더되고, 비긴급 업데이트(10,000개 항목 필터링)는 사용자가 다시 입력하면 중단될 수 있습니다. isPending은 transition이 진행 중임을 나타냅니다(미묘한 로딩 표시기 표시). 이는 비용이 많이 드는 렌더 중 UI가 멈추는 것을 방지합니다. 핵심 통찰: React는 오래된 transition을 중단하고 폐기하여 UI를 응답성 있게 유지할 수 있습니다. 검색 필터링, 탭 전환, 무거운 렌더링을 트리거하는 모든 state 업데이트에 사용하세요. 긴급 업데이트(입력, 클릭)를 transition으로 감싸지 마세요.
import { useTransition, useState } from "react";
function SearchResults() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const handleSearch = (value) => {
setQuery(value); // urgent: update input immediately
startTransition(() => {
// non-urgent: heavy filtering can be interrupted
const filtered = heavyFilter(allItems, value);
setResults(filtered);
});
};
return (
<div>
<input value={query} onChange={(e) => handleSearch(e.target.value)} />
{isPending && <span>Updating...</span>}
<ul>{results.map((r) => <li key={r.id}>{r.name}</li>)}</ul>
</div>
);
}useDeferredValue
useDeferredValue는 useTransition의 선언적 대응입니다. 더 낮은 우선순위로 업데이트되는 값의 지연된 복사본을 반환합니다. 입력은 즉시 업데이트되고(긴급), 비용이 많이 드는 리스트는 지연된 값으로 리렌더됩니다(비긴급). Results 컴포넌트의 React.memo가 중요합니다 - 매 키 입력마다 리렌더를 방지하고, deferredQuery가 변경될 때만 리렌더합니다. isStale(현재 vs 지연 비교)로 시각적 표시기(흐려짐, spinner)를 표시할 수 있습니다. state 업데이트를 제어할 수 없을 때(예: 값이 prop에서 옴) useDeferredValue를 사용하세요. 업데이트를 제어할 때 useTransition을 사용하세요.
function Search() {
const [query, setQuery] = useState("");
// query updates immediately; deferredQuery lags behind
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results query={deferredQuery} isStale={isStale} />
</div>
);
}
// Memoize the expensive component so it only re-renders
// when deferredQuery changes (not on every keystroke)
const Results = React.memo(function Results({ query, isStale }) {
const items = expensiveSearch(query); // heavy computation
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
{items.map((i) => <div key={i.id}>{i.name}</div>)}
</div>
);
});useOptimistic (React 19)
useOptimistic(React 19)은 낙관적 업데이트를 구현합니다 - UI는 예상 결과로 즉시 업데이트되고, 그런 다음 실제 서버 응답과 조정됩니다. 낙관적 state는 비동기 작업 중 표시되고, 실제 데이터가 도착하면(새 prop으로 컴포넌트 리렌더), 낙관적 값이 자동으로 교체됩니다. 작업이 실패하면, 낙관적 업데이트는 변경되지 않은 prop으로 다음 렌더에서 단순히 되돌아갑니다. 이는 수동 낙관적 업데이트 로직(보류 중 state 추적, 오류 시 되돌리기)을 제거합니다. 로딩 표시기를 표시하기 위해 보류 중 항목(pending: true)을 표시하세요. 좋아요, 댓글, 토글에 완벽합니다.
import { useOptimistic } from "react";
function ThumbsUp({ likes, addLike }) {
// Optimistic state: updates immediately, reverts on error
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(state, newLike) => [...state, newLike]
);
const handleClick = async () => {
const newLike = { id: Date.now(), pending: true };
addOptimisticLike(newLike); // instant UI update
try {
await addLike(newLike); // actual API call
} catch {
// Reverts automatically on re-render with real data
}
};
return (
<div>
<button onClick={handleClick}>👍 {optimisticLikes.length}</button>
{optimisticLikes.some((l) => l.pending) && <span>Saving...</span>}
</div>
);
}use (React 19 Hook)
use()는 React 19의 새 hook으로 context나 promise를 읽습니다. useContext와 달리, use()는 조건부로 호출될 수 있습니다(if 문, 루프 내부) - rules-of-hooks 제한이 없습니다. promise의 경우, use()는 promise가 해결될 때까지 컴포넌트를 일시 중단합니다(Suspense 경계 필요). promise는 부모에서 생성되고 prop으로 전달됩니다 - 이는 useEffect가 아닌 렌더 중 가져오기를 시작하여, waterfall을 더 일찍 시작하게 합니다. 같은 promise가 여러 컴포넌트에 전달될 수 있습니다(중복 제거). use()는 동기 context와 비동기 데이터 간의 간격을 연결합니다.
import { use } from "react";
// Read context with use (works in conditions!)
function Theme() {
// Unlike useContext, use() can be inside conditions
if (showTheme) {
const theme = use(ThemeContext); // conditional context!
return <div style={{ background: theme.color }} />;
}
return null;
}
// Read promises with use (Suspense integration)
function UserProfile({ userPromise }) {
// Suspends until promise resolves
const user = use(userPromise);
return <h1>{user.name}</h1>;
}
// Parent passes promise (starts fetching during render)
function App() {
const userPromise = fetchUser(); // starts immediately
return (
<Suspense fallback={<Loading />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}Concurrent 렌더링 패턴
Concurrent 패턴: 비긴급 탭 전환을 위한 useTransition(무거운 콘텐츠가 렌더되는 동안 nav를 응답성 있게 유지). useSyncExternalStore는 concurrent 모드에서 외부 저장소(브라우저 API, Redux, Zustand)에 안전하게 구독합니다 - 클라이언트와 서버를 위한 snapshot 함수를 제공합니다(SSR 안전). render에서 외부 변경 가능 state를 직접 사용하지 마세요(tearing 위험); 항상 useSyncExternalStore를 거치세요. 세 인수: subscribe(cleanup 반환), getSnapshot(현재 값), getServerSnapshot(SSR 초기 값). 이는 concurrent 렌더링 중 일관된 읽기를 보장합니다. Redux와 Zustand 같은 라이브러리가 이를 내부적으로 사용합니다.