| title | impact | impactDescription | tags |
|---|---|---|---|
| 사용 시점까지 상태 읽기 지연 | MEDIUM | 불필요한 구독 방지 | rerender, searchParams, localStorage, optimization |
사용 시점까지 상태 읽기 지연
callbacks 안에서만 읽는 동적 상태(searchParams, localStorage)는 component level에서 구독하지 마세요.
Incorrect (매번 search params 변경 시 rerender):
import { useSearchParams } from "next/navigation";
function ShareButton() {
const searchParams = useSearchParams(); // 변경될 때마다 rerender
const handleShare = () => {
const url = `${window.location.pathname}?${searchParams}`;
navigator.share({ url });
};
return <button onClick={handleShare}>Share</button>;
}Correct (필요할 때만 직접 접근):
function ShareButton() {
const handleShare = () => {
// callback 내에서 직접 접근
const url = `${window.location.pathname}${window.location.search}`;
navigator.share({ url });
};
return <button onClick={handleShare}>Share</button>;
}| title | impact | impactDescription | tags |
|---|---|---|---|
| Effect Dependencies 좁히기 | LOW | effect 재실행 최소화 | rerender, useEffect, dependencies, optimization |
Effect Dependencies 좁히기
객체 대신 primitive dependencies를 지정하여 effect 재실행을 최소화하세요.
Incorrect (객체의 어떤 property라도 변경되면 effect 실행):
useEffect(() => {
fetchUserData(user.id);
}, [user]); // user 객체 전체를 dependency로 사용Correct (필요한 primitive 값만 dependency로 사용):
useEffect(() => {
fetchUserData(user.id);
}, [user.id]); // id가 변경될 때만 effect 실행파생 상태 패턴:
// Incorrect (width가 변경될 때마다 effect 실행)
useEffect(() => {
updateLayout(width < 768);
}, [width]);
// Correct (boolean이 변경될 때만 effect 실행)
const isMobile = width < 768;
useEffect(() => {
updateLayout(isMobile);
}, [isMobile]);| title | impact | impactDescription | tags |
|---|---|---|---|
| 렌더링 중 파생 상태 계산하기 | MEDIUM | 중복 렌더와 상태 불일치 방지 | rerender, derived-state, useEffect, state |
렌더링 중 파생 상태 계산하기
현재 props/state에서 계산 가능한 값은 state에 저장하거나 effect에서 업데이트하지 마세요. 렌더링 중 파생하여 추가 렌더와 상태 불일치를 방지하세요. prop 변경에 대응하여 effect에서 state를 설정하지 말고, 파생 값이나 keyed reset을 선호하세요.
Incorrect (중복 state와 effect):
function Form() {
const [firstName, setFirstName] = useState("First");
const [lastName, setLastName] = useState("Last");
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(firstName + " " + lastName);
}, [firstName, lastName]);
return <p>{fullName}</p>;
}Correct (렌더링 중 파생):
function Form() {
const [firstName, setFirstName] = useState("First");
const [lastName, setLastName] = useState("Last");
const fullName = firstName + " " + lastName;
return <p>{fullName}</p>;
}Reference: You Might Not Need an Effect
| title | impact | impactDescription | tags |
|---|---|---|---|
| 파생 상태 구독하기 | MEDIUM | rerender 빈도 감소 | rerender, derived-state, media-query, optimization |
파생 상태 구독하기
연속적인 값 대신 파생된 boolean 상태를 구독하여 rerender 빈도를 줄이세요.
Incorrect (픽셀 변경마다 rerender):
function Sidebar() {
const width = useWindowWidth(); // 지속적으로 업데이트
const isMobile = width < 768;
return <nav className={isMobile ? "mobile" : "desktop"} />;
}Correct (boolean이 변경될 때만 rerender):
function Sidebar() {
const isMobile = useMediaQuery("(max-width: 767px)");
return <nav className={isMobile ? "mobile" : "desktop"} />;
}| title | impact | impactDescription | tags |
|---|---|---|---|
| 함수형 setState 업데이트 사용 | MEDIUM | 안정적인 callback 참조, stale closure 방지 | rerender, useState, useCallback, functional-update |
함수형 setState 업데이트 사용
현재 state 값에 기반하여 state를 업데이트할 때는 state 변수를 직접 참조하는 대신 함수형 업데이트 형태를 사용하세요. 이는 stale closures를 방지하고, 불필요한 dependencies를 제거하며, 안정적인 callback 참조를 생성합니다.
Incorrect (state를 dependency로 필요):
function TodoList() {
const [items, setItems] = useState(initialItems);
// items 변경마다 callback 재생성
const addItems = useCallback(
(newItems: Item[]) => {
setItems([...items, ...newItems]);
},
[items],
); // items dependency로 인해 재생성
// dependency 누락 시 stale closure 위험
const removeItem = useCallback((id: string) => {
setItems(items.filter(item => item.id !== id));
}, []); // items dependency 누락 - stale items 사용!
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />;
}Correct (안정적인 callbacks, stale closure 없음):
function TodoList() {
const [items, setItems] = useState(initialItems);
// 안정적인 callback, 재생성 없음
const addItems = useCallback((newItems: Item[]) => {
setItems(curr => [...curr, ...newItems]);
}, []); // dependencies 불필요
// 항상 최신 state 사용, stale closure 위험 없음
const removeItem = useCallback((id: string) => {
setItems(curr => curr.filter(item => item.id !== id));
}, []); // 안전하고 안정적
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />;
}Benefits:
- 안정적인 callback 참조 - state 변경 시 callbacks 재생성 불필요
- stale closures 없음 - 항상 최신 state 값으로 작동
- 적은 dependencies - dependency arrays 단순화, 메모리 누수 감소
- 버그 방지 - React closure 버그의 가장 흔한 원인 제거
함수형 업데이트 사용 시점:
- 현재 state 값에 의존하는 모든 setState
- useCallback/useMemo 내부에서 state가 필요할 때
- state를 참조하는 event handlers
- state를 업데이트하는 async operations
직접 업데이트가 괜찮은 경우:
- 정적 값으로 state 설정:
setCount(0) - props/arguments만으로 state 설정:
setName(newName) - state가 이전 값에 의존하지 않을 때
Note: React Compiler가 활성화된 프로젝트에서는 컴파일러가 일부 케이스를 자동 최적화할 수 있지만, 정확성과 stale closure 버그 방지를 위해 함수형 업데이트가 여전히 권장됩니다.
| title | impact | impactDescription | tags |
|---|---|---|---|
| Lazy State 초기화 사용 | MEDIUM | 매 렌더마다 낭비되는 계산 방지 | react, hooks, useState, performance, initialization |
Lazy State 초기화 사용
비용이 큰 계산으로 React state를 초기화할 때는 함수를 직접 호출하는 대신 useState에 함수를 전달하세요. 이렇게 하면 initializer가 후속 렌더가 아닌 초기 렌더에서만 실행됩니다.
Incorrect (매 렌더마다 비용 큰 함수 실행):
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex()가 초기화 후에도 매 렌더마다 실행됨
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items));
const [query, setQuery] = useState("");
return <SearchResults index={searchIndex} query={query} />;
}
function UserProfile() {
// JSON.parse가 매 렌더마다 실행됨
const [settings, setSettings] = useState(JSON.parse(localStorage.getItem("settings") || "{}"));
return <SettingsForm settings={settings} onChange={setSettings} />;
}Correct (초기 렌더에서만 실행):
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex()가 초기 렌더에서만 실행됨
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items));
const [query, setQuery] = useState("");
return <SearchResults index={searchIndex} query={query} />;
}
function UserProfile() {
// JSON.parse가 초기 렌더에서만 실행됨
const [settings, setSettings] = useState(() => {
const stored = localStorage.getItem("settings");
return stored ? JSON.parse(stored) : {};
});
return <SettingsForm settings={settings} onChange={setSettings} />;
}사용 시점:
- localStorage/sessionStorage 작업
- 데이터 구조 구축 (indexes, maps)
- DOM reads
- 비용 큰 transformations
primitives, 직접적인 prop 참조, 또는 비용이 적은 literals 같은 단순한 값에는 함수 형태를 건너뛰세요.
| title | impact | impactDescription | tags |
|---|---|---|---|
| Memoized Component에서 Non-primitive 기본값을 상수로 추출 | MEDIUM | 상수 사용으로 memoization 복원 | rerender, memo, optimization |
Memoized Component에서 Non-primitive 기본값을 상수로 추출
memoized component가 non-primitive optional parameter(배열, 함수, 객체)의 기본값을 포함할 때, 해당 parameter를 생략하면 memoization이 깨집니다. 매 rerender마다 새로운 값 인스턴스가 생성되고, memo()의 strict equality 비교를 통과하지 못하기 때문입니다.
Incorrect (매 렌더마다 새 함수 인스턴스 생성):
const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
// ...
});
// optional onClick 없이 사용
<UserAvatar />;Correct (상수로 추출):
const NOOP = () => {};
const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
// ...
});
// optional onClick 없이 사용
<UserAvatar />;안정적인 참조를 설정함으로써, memoized component가 변경되지 않은 props를 올바르게 식별하고 불필요한 rerenders를 건너뜁니다.
| title | impact | impactDescription | tags |
|---|---|---|---|
| Memoized Components로 추출 | MEDIUM | early returns 활성화 | rerender, memo, useMemo, optimization |
Memoized Components로 추출
비용 큰 작업이 실행되기 전에 early returns를 허용하기 위해 계산적으로 비용이 큰 작업을 별도의 memoized components로 추출하세요.
Incorrect (loading 상태와 관계없이 avatar 계산 발생):
function UserProfile({ userId, loading }: Props) {
// loading이 true여도 비용 큰 계산이 실행됨
const avatarId = useMemo(() => computeAvatarId(userId), [userId]);
if (loading) return <Skeleton />;
return <Avatar id={avatarId} />;
}Correct (avatar component가 렌더될 때만 계산):
const UserAvatar = memo(function UserAvatar({ userId }: { userId: string }) {
const avatarId = useMemo(() => computeAvatarId(userId), [userId]);
return <Avatar id={avatarId} />;
});
function UserProfile({ userId, loading }: Props) {
if (loading) return <Skeleton />;
// 이 component가 마운트될 때만 계산 발생
return <UserAvatar userId={userId} />;
}React Compiler Note: React Compiler가 활성화된 프로젝트에서는 memo()와 useMemo()를 사용한 수동 memoization이 필요하지 않습니다. 컴파일러가 자동으로 re-renders를 최적화합니다.
| title | impact | impactDescription | tags |
|---|---|---|---|
| 상호작용 로직을 Event Handlers에 넣기 | MEDIUM | effect 재실행과 중복 side effects 방지 | rerender, useEffect, events, side-effects, dependencies |
상호작용 로직을 Event Handlers에 넣기
side effect가 특정 사용자 액션(submit, click, drag)에 의해 트리거된다면, 해당 event handler에서 실행하세요. 액션을 state + effect로 모델링하지 마세요. 이는 관련 없는 변경에도 effects를 재실행하게 만들고 액션을 중복시킬 수 있습니다.
Incorrect (event가 state + effect로 모델링됨):
function Form() {
const [submitted, setSubmitted] = useState(false);
const theme = useContext(ThemeContext);
useEffect(() => {
if (submitted) {
post("/api/register");
showToast("Registered", theme);
}
}, [submitted, theme]);
return <button onClick={() => setSubmitted(true)}>Submit</button>;
}Correct (handler에서 직접 실행):
function Form() {
const theme = useContext(ThemeContext);
function handleSubmit() {
post("/api/register");
showToast("Registered", theme);
}
return <button onClick={handleSubmit}>Submit</button>;
}Reference: Should this code move to an event handler?
| title | impact | impactDescription | tags |
|---|---|---|---|
| Components 내부에서 Components 정의하지 않기 | HIGH | 매 렌더마다 remount 방지 | rerender, components, remount, state-loss |
Components 내부에서 Components 정의하지 않기
다른 component 내부에서 component를 정의하면 매 렌더마다 새로운 component type이 생성됩니다. React는 매번 다른 component로 인식하여 완전히 remount하고, 모든 state와 DOM을 파괴합니다.
개발자들이 이렇게 하는 흔한 이유는 props를 전달하지 않고 부모 변수에 접근하기 위해서입니다. 항상 props를 전달하세요.
Incorrect (매 렌더마다 remount):
function UserProfile({ user, theme }) {
// theme에 접근하기 위해 내부에 정의 - BAD
const Avatar = () => (
<img src={user.avatarUrl} className={theme === "dark" ? "avatar-dark" : "avatar-light"} />
);
// user에 접근하기 위해 내부에 정의 - BAD
const Stats = () => (
<div>
<span>{user.followers} followers</span>
<span>{user.posts} posts</span>
</div>
);
return (
<div>
<Avatar />
<Stats />
</div>
);
}UserProfile이 렌더될 때마다, Avatar와 Stats는 새로운 component types입니다. React는 기존 instances를 unmount하고 새 것을 mount하여 internal state를 잃고, effects를 다시 실행하고, DOM nodes를 재생성합니다.
Correct (props를 대신 전달):
function Avatar({ src, theme }: { src: string; theme: string }) {
return <img src={src} className={theme === "dark" ? "avatar-dark" : "avatar-light"} />;
}
function Stats({ followers, posts }: { followers: number; posts: number }) {
return (
<div>
<span>{followers} followers</span>
<span>{posts} posts</span>
</div>
);
}
function UserProfile({ user, theme }) {
return (
<div>
<Avatar src={user.avatarUrl} theme={theme} />
<Stats followers={user.followers} posts={user.posts} />
</div>
);
}이 버그의 증상:
- 매 keystroke마다 input fields가 focus를 잃음
- 예상치 못하게 animations가 재시작됨
- 매 부모 렌더마다
useEffectcleanup/setup이 실행됨 - component 내부의 scroll position이 리셋됨
| title | impact | impactDescription | tags |
|---|---|---|---|
| Primitive 결과를 반환하는 단순 표현식을 useMemo로 감싸지 않기 | LOW-MEDIUM | 매 렌더마다 낭비되는 계산 | rerender, useMemo, optimization |
Primitive 결과를 반환하는 단순 표현식을 useMemo로 감싸지 않기
primitives를 반환하는 간단한 표현식에는 useMemo를 사용하지 마세요. useMemo를 호출하고 hook dependencies를 비교하는 것이 표현식 자체보다 더 많은 리소스를 소비할 수 있습니다.
Incorrect (단순 boolean 연산에 useMemo):
const isLoading = useMemo(
() => user.isLoading || notifications.isLoading,
[user.isLoading, notifications.isLoading],
);Correct (직접 계산):
const isLoading = user.isLoading || notifications.isLoading;primitives (boolean, number, string)를 생성하는 단순한 논리 또는 산술 연산에서는 hook의 dependency 비교 overhead가 값을 재계산하는 비용을 초과합니다.
| title | impact | impactDescription | tags |
|---|---|---|---|
| 결합된 Hook 계산 분리하기 | LOW-MEDIUM | dependencies별로 독립적 실행 | rerender, useMemo, useEffect, dependencies |
결합된 Hook 계산 분리하기
hook이 서로 다른 dependencies를 가진 여러 독립적인 작업을 포함할 때, 별도의 hooks로 분리하세요. 결합된 hook은 어떤 dependency라도 변경되면 일부 작업이 변경된 값을 사용하지 않더라도 모든 작업을 다시 실행합니다.
Incorrect (sortOrder 변경이 filtering도 재계산):
const sortedProducts = useMemo(() => {
const filtered = products.filter(p => p.category === category);
const sorted = filtered.toSorted((a, b) =>
sortOrder === "asc" ? a.price - b.price : b.price - a.price,
);
return sorted;
}, [products, category, sortOrder]);Correct (filtering은 products나 category 변경 시에만 재계산):
const filteredProducts = useMemo(
() => products.filter(p => p.category === category),
[products, category],
);
const sortedProducts = useMemo(
() =>
filteredProducts.toSorted((a, b) =>
sortOrder === "asc" ? a.price - b.price : b.price - a.price,
),
[filteredProducts, sortOrder],
);이 패턴은 관련 없는 side effects를 결합할 때 useEffect에도 적용됩니다:
Incorrect (둘 중 하나의 dependency가 변경되면 두 effects 모두 실행):
useEffect(() => {
analytics.trackPageView(pathname);
document.title = `${pageTitle} | My App`;
}, [pathname, pageTitle]);Correct (effects가 독립적으로 실행):
useEffect(() => {
analytics.trackPageView(pathname);
}, [pathname]);
useEffect(() => {
document.title = `${pageTitle} | My App`;
}, [pageTitle]);Note: React Compiler가 활성화된 프로젝트에서는 자동으로 dependency tracking을 최적화하고 이런 케이스 일부를 처리할 수 있습니다.
| title | impact | impactDescription | tags |
|---|---|---|---|
| 긴급하지 않은 업데이트에 Transitions 사용 | MEDIUM | UI 반응성 유지 | rerender, transitions, startTransition, performance |
긴급하지 않은 업데이트에 Transitions 사용
빈번하고 긴급하지 않은 state 업데이트를 transitions로 표시하여 UI 반응성을 유지하세요.
Incorrect (매 scroll마다 UI 블로킹):
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handler = () => setScrollY(window.scrollY);
window.addEventListener("scroll", handler, { passive: true });
return () => window.removeEventListener("scroll", handler);
}, []);
}Correct (non-blocking 업데이트):
import { startTransition } from "react";
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handler = () => {
startTransition(() => setScrollY(window.scrollY));
};
window.addEventListener("scroll", handler, { passive: true });
return () => window.removeEventListener("scroll", handler);
}, []);
}| title | impact | impactDescription | tags |
|---|---|---|---|
| 비용 큰 파생 렌더에 useDeferredValue 사용 | MEDIUM | 무거운 계산 중 input 반응성 유지 | rerender, useDeferredValue, optimization, concurrent |
비용 큰 파생 렌더에 useDeferredValue 사용
useDeferredValue hook은 사용자 input이 계산적으로 비용이 큰 작업을 트리거할 때 성능 문제를 해결합니다. 값 업데이트를 지연시켜 React가 즉각적인 input 반응성을 우선시하면서 유휴 기간 동안 비용 큰 계산을 처리합니다.
Incorrect (매 keystroke마다 비용 큰 필터링):
function SearchResults({ items }: Props) {
const [query, setQuery] = useState("");
// 매 keystroke마다 비용 큰 필터링 실행
const filtered = items.filter(item => item.name.toLowerCase().includes(query.toLowerCase()));
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</>
);
}Correct (input은 즉시 반응, 결과는 비동기 업데이트):
import { useDeferredValue, useMemo } from "react";
function SearchResults({ items }: Props) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
// deferred value로 비용 큰 계산을 useMemo로 감싸기
const filtered = useMemo(
() => items.filter(item => item.name.toLowerCase().includes(deferredQuery.toLowerCase())),
[items, deferredQuery],
);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul style={{ opacity: query !== deferredQuery ? 0.7 : 1 }}>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</>
);
}최적의 사용 사례:
- 대규모 list filtering이나 search 작업
- 사용자 input에 반응하는 복잡한 visualizations
- 눈에 띄는 렌더 지연을 유발하는 파생 계산
중요: deferred value를 dependency로 사용하여 비용 큰 계산을 useMemo로 감싸세요. 그렇지 않으면 여전히 매 렌더마다 실행됩니다.
Reference: React useDeferredValue Documentation
| title | impact | impactDescription | tags |
|---|---|---|---|
| 일시적 값에 useRef 사용 | MEDIUM | 빈번한 업데이트에서 불필요한 re-renders 방지 | rerender, useref, state, performance |
일시적 값에 useRef 사용
값이 UI 업데이트를 요구하지 않고 빈번하게 변경될 때, useState보다 useRef가 더 적합합니다. 이 패턴은 UI를 위한 component state를 유지하고 일시적인 DOM 인접 값에는 refs를 사용합니다. Ref 업데이트는 re-render를 트리거하지 않습니다.
Incorrect (매 mouse 이동마다 render):
function Follower() {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e: MouseEvent) => {
setPos({ x: e.clientX, y: e.clientY }); // 매 이동마다 render!
};
window.addEventListener("mousemove", handler);
return () => window.removeEventListener("mousemove", handler);
}, []);
return (
<div
style={{
transform: `translate(${pos.x}px, ${pos.y}px)`,
}}
className="follower-dot"
/>
);
}Correct (직접 DOM 조작으로 render 없음):
function Follower() {
const dotRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dotRef.current) {
dotRef.current.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`;
}
};
window.addEventListener("mousemove", handler);
return () => window.removeEventListener("mousemove", handler);
}, []);
return <div ref={dotRef} className="follower-dot" />;
}핵심 포인트:
렌더된 output에 영향을 주는 값에는 React state를 예약하세요. 빈번하게 변경되지만 UI 동기화가 필요하지 않은 값—mouse 좌표, timers, 임시 flags, 유사한 일시적 데이터—에는 refs를 사용하여 최적의 렌더링 성능을 유지하세요.