Vercel의 React Best Practices 톺아보기 [5]
5. 리렌더링 최적화 - 중간 (MEDIUM)
불필요한 재렌더링을 줄이면 낭비되는 연산이 최소화되고 UI 응답성이 향상됩니다.
5.1 Calculate Derived State During Rendering (파생된 상태는 렌더링 중에 계산하세요.)
- 영향도: 중간 (MEDIUM / 불필요한 리렌더, 상태 불일치 방지)
현재 props/state 상태만으로 계산할 수 있는 값이면 state에 넣지 말고, effect로 업데이트 하지 마세요. 불필요한 렌더링과 state 변화를 방지하기 위해 렌더링 중에 값을 도출하세요. props가 바뀔 때마다 effect로만 state를 맞추는 패턴은 피하고, 파생 값이나 key로 컴포넌트를 리셋하는 쪽을 우선하세요.
잘못된 예 (불필요한 state + effect):
function Form() {
const [firstName, setFirstName] = useState("First");
const [lastName, setLastName] = useState("Last");
const [fullName, setFullName] = useState("");
// firstName/lastName이 바뀔 때마다 한 턴 늦게 fullName이 갱신될 수 있음
useEffect(() => {
setFullName(firstName + " " + lastName);
}, [firstName, lastName]);
return <p>{fullName}</p>;
}올바른 예 (렌더 중에 파생):
function Form() {
const [firstName, setFirstName] = useState("First");
const [lastName, setLastName] = useState("Last");
// 매 렌더마다 최신 first/last로 계산 — 별도 state 불필요
const fullName = firstName + " " + lastName;
return <p>{fullName}</p>;
}참고: You Might Not Need an Effect
5.2 Defer State Reads to Usage Point (상태 읽기는 실제 쓰는 시점으로 미루세요.)
- 영향도: 중간 (MEDIUM / 불필요한 구독 방지)
콜백 함수 내에서만 동적 상태(searchParams, localStorage)를 읽는 경우에는 해당 동적 상태를 구독하지 마세요.
잘못된 예 (searchParams 변경마다 컴포넌트가 구독):
function ShareButton({ chatId }: { chatId: string }) {
const searchParams = useSearchParams();
const handleShare = () => {
const ref = searchParams.get("ref");
shareChat(chatId, { ref });
};
return <button onClick={handleShare}>Share</button>;
}올바른 예 (필요할 때만 읽기, 구독 없음):
function ShareButton({ chatId }: { chatId: string }) {
const handleShare = () => {
// 클릭 시점의 URL만 읽음 — useSearchParams 리렌더와 무관
const params = new URLSearchParams(window.location.search);
const ref = params.get("ref");
shareChat(chatId, { ref });
};
return <button onClick={handleShare}>Share</button>;
}5.3 Do not wrap a simple expression with a primitive result type in useMemo (단순한 원시값 표현식은 useMemo로 감싸지 마세요.)
- 영향도: 중하 (LOW-MEDIUM / 렌더링할 때마다 불필요한 연산 발생)
표현식이 단순(논리·산술 연산 몇 개 수준)하고 결과가 원시 타입(boolean, number, string)이면 useMemo로 감싸지 마세요. useMemo 호출과 의존성 비교만으로도, 그 표현식 자체보다 비용이 더 나갈 수 있습니다.
잘못된 예:
function Header({ user, notifications }: Props) {
const isLoading = useMemo(() => {
return user.isLoading || notifications.isLoading;
}, [user.isLoading, notifications.isLoading]);
if (isLoading) return <Skeleton />;
// 마크업 반환
}올바른 예:
function Header({ user, notifications }: Props) {
// boolean 하나면 그냥 매 렌더에 계산하는 편이 가볍다
const isLoading = user.isLoading || notifications.isLoading;
if (isLoading) return <Skeleton />;
// 마크업 반환
}5.4 Don't Define Components Inside Components (컴포넌트 안에서 컴포넌트를 정의하지 마세요.)
- 영향도: 높음 (HIGH / 렌더링 시마다 재마운트를 방지)
한 컴포넌트 함수 본문 안에서 또 다른 컴포넌트를 정의하면, 렌더할 때마다 새로운 컴포넌트 타입이 생깁니다. React는 매번 다른 컴포넌트로 보고 완전히 언마운트 후 다시 마운트하므로, 내부 state와 DOM이 초기화됩니다.
이렇게 쓰는 흔한 이유는 부모 변수에 접근하려고 props를 안 넘기려는 경우입니다. 대신 props로 넘기세요.
잘못된 예 (부모가 리렌더될 때마다 자식 리마운트):
function UserProfile({ user, theme }) {
// theme에 접근하기 위해 내부 정의 — 비권장
const Avatar = () => (
<img
src={user.avatarUrl}
className={theme === "dark" ? "avatar-dark" : "avatar-light"}
/>
);
// user에 접근하기 위해 내부 정의 — 비권장
const Stats = () => (
<div>
<span>{user.followers} followers</span>
<span>{user.posts} posts</span>
</div>
);
return (
<div>
<Avatar />
<Stats />
</div>
);
}UserProfile이 리렌더될 때마다 Avatar와 Stats는 새 타입입니다. React는 이전 인스턴스를 언마운트하고 새로 마운트하므로, 내부 state가 사라지고 effect가 다시 돌고 DOM 노드도 다시 만들어지게 됩니다.
올바른 예 (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>
);
}이 버그의 흔한 증상:
- 입력창이 타이핑 할 때마다 포커스가 풀림
- 애니메이션이 갑자기 처음부터 다시 시작됨
- 부모가 리렌더될 때마다
useEffectcleanup/setup이 반복 - 컴포넌트 내부의 스크롤 위치가 리셋됨
5.5 Extract Default Non-primitive Parameter Value from Memoized Component to Constant (메모이제이션된 컴포넌트에서 default 비원시 매개변수 값은 상수로 저장하세요.)
- 영향도: 중간 (MEDIUM / 기본값마다 새 참조가 생겨 memo가 깨지는 것 방지)
memo로 감싼 컴포넌트에 배열, 함수, 객체 같은 비원시(참조형) 선택 파라미터에 기본값을 두게되면 그 인자를 넘기지 않고 쓸 때마다 렌더마다 새 인스턴스가 생깁니다. memo()는 보통 참조 동일성으로 props를 비교하므로, 매번 다른 onClick 으로 판단해 메모이제이션이 깨집니다.
기본값은 모듈 상수로 한 번만 만들어 두고 재사용하세요.
잘못된 예 (onClick이 리렌더마다 다른 참조):
const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
// () => {} 가 렌더마다 새 함수 → memo props가 항상 바뀐 것처럼 보임
// ...
})
// onClick 생략
<UserAvatar />올바른 예 (안정적인 기본값):
const NOOP = () => {}
const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
// onClick 미전달 시 항상 같은 NOOP 참조
// ...
})
// onClick 생략
<UserAvatar />5.6 Extract to Memoized Components (메모제이션된 컴포넌트로 추출하세요.)
- 영향도: 중간 (MEDIUM / 불필요한 계산, JSX 생성 전에 return)
잘못된 예 (loading이어도 avatar 계산이 먼저 돌아감):
function Profile({ user, loading }: Props) {
const avatar = useMemo(() => {
const id = computeAvatarId(user);
return <Avatar id={id} />;
}, [user]);
// loading이 true여도 위 useMemo는 이미 실행됨
if (loading) return <Skeleton />;
return <div>{avatar}</div>;
}올바른 예 (loading이면 UserAvatar 자체를 렌더하지 않음):
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
const id = useMemo(() => computeAvatarId(user), [user]);
return <Avatar id={id} />;
});
function Profile({ user, loading }: Props) {
if (loading) return <Skeleton />;
return (
<div>
<UserAvatar user={user} />
</div>
);
}참고: 프로젝트에 React Compiler가 켜져 있으면 memo()·useMemo()를 수동으로 쓸 필요가 없을 수 있습니다. 컴파일러가 리렌더를 자동으로 최적화합니다.
5.7 Narrow Effect Dependencies (Effect의 의존성 배열 범위를 좁히세요.)
- 영향도: 낮음 (LOW / Effect 재실행 최소화)
객체 전체 대신 원시값(id, boolean 등)만 의존성에 넣으면, effect가 꼭 필요할 때만 다시 실행됩니다.
잘못된 예 (user의 아무 필드가 바뀌어도 effect 재실행):
useEffect(() => {
console.log(user.id);
}, [user]);올바른 예 (id가 바뀔 때만 재실행):
useEffect(() => {
console.log(user.id);
}, [user.id]);파생 값은 effect 밖에서 만들고, 그 결과만 의존성에:
// 잘못됨: width가 767, 766, 765… 바뀔 때마다 effect 실행
useEffect(() => {
if (width < 768) {
enableMobileMode();
}
}, [width]);
// 올바름: isMobile true/false가 바뀔 때만 (덜 자주)
const isMobile = width < 768;
useEffect(() => {
if (isMobile) {
enableMobileMode();
}
}, [isMobile]);5.8 Put Interaction Logic in Event Handlers (상호작용 로직은 이벤트 핸들러에 두세요.)
- 영향도: 중간 (MEDIUM / Effect의 재실행, 중복 부작용 방지)
부수 효과가 특정 사용자 동작(제출, 클릭, 드래그) 때문에 일어난다면, onClick 등 핸들러 안에서 실행하세요. state를 올렸다가 effect에서 처리하는 식으로 모델링하면, 무관한 값이 바뀔 때도 effect가 다시 돌고, 같은 동작이 중복 실행되기 쉽습니다.
잘못된 예 (이벤트를 state + effect로 표현):
function Form() {
const [submitted, setSubmitted] = useState(false);
const theme = useContext(ThemeContext);
// theme만 바뀌어도 submitted가 true면 post/showToast가 또 실행될 수 있음
useEffect(() => {
if (submitted) {
post("/api/register");
showToast("Registered", theme);
}
}, [submitted, theme]);
return <button onClick={() => setSubmitted(true)}>Submit</button>;
}올바른 예 (핸들러에서 바로 처리):
function Form() {
const theme = useContext(ThemeContext);
function handleSubmit() {
post("/api/register");
showToast("Registered", theme);
}
return <button onClick={handleSubmit}>Submit</button>;
}참고: Should this code move to an event handler?
5.9 Split Combined Hook Computations (한 hook에 묶인 계산을 분할하세요.)
- 영향도: 중간 (MEDIUM / 독립된 단계의 재계산 방지)
한 훅 안에 서로 다른 deps를 가진 작업이 여러 개 있으면 훅을 쪼개세요. 한 덩어리로 두면 어느 하나의 deps만 바뀌어도 나머지 단계까지 전부 다시 돕니다.
잘못된 예 (sortOrder만 바꿔도 필터링부터 다시 함):
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]);올바른 예 (필터는 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]
);useEffect에서 관계없는 부수 효과를 한 effect에 넣은 경우에도 같은 패턴입니다.
잘못된 예 (pathname, pageTitle 중 하나만 바뀌어도 둘 다 실행):
useEffect(() => {
analytics.trackPageView(pathname);
document.title = `${pageTitle} | My App`;
}, [pathname, pageTitle]);올바른 예 (각각 독립적으로 실행):
useEffect(() => {
analytics.trackPageView(pathname);
}, [pathname]);
useEffect(() => {
document.title = `${pageTitle} | My App`;
}, [pageTitle]);참고: React Compiler를 켜 두면 의존성 추적이 자동으로 최적화되어, 이런 경우 일부는 컴파일러가 처리해 줄 수 있습니다.
5.10 Subscribe to Derived State (파생(boolean) 상태에만 구독하세요.)
- 영향도: 중간 (MEDIUM / 리렌더링 빈도 줄임)
창 너비처럼 연속적으로 변하는 값 전체에 구독하면 리사이즈할 때마다 리렌더될 수 있습니다. 화면에 필요한 게 width < 768 같은 참/거짓뿐이면, boolean값에만 구독해 true/false가 바뀔 때만 리렌더되게 하세요.
잘못된 예 (픽셀이 바뀔 때마다 리렌더):
function Sidebar() {
const width = useWindowWidth(); // 리사이즈 중 계속 갱신될 수 있음
const isMobile = width < 768;
return <nav className={isMobile ? "mobile" : "desktop"} />;
}올바른 예 (boolean이 바뀔 때만 리렌더):
function Sidebar() {
const isMobile = useMediaQuery("(max-width: 767px)");
return <nav className={isMobile ? "mobile" : "desktop"} />;
}5.11 Use Functional setState Updates (setState는 함수형 업데이트를 사용하세요.)
- 영향도: 중간 (MEDIUM / 오래된 클로저, 불필요한 콜백 재생성 방지)
현재 state 값을 보고 다음 값을 정할 때는, state 변수를 직접 참조하지 말고 setState(이전값 => 새값) 형태의 함수형 업데이트를 쓰세요. 오래된 클로저(stale closure)를 막고, useCallback의 의존성에서 state를 빼도 되어 콜백 참조를 안정시키기 쉽습니다.
잘못된 예 (state를 deps에 넣어야 해서 콜백이 자주 바뀜):
function TodoList() {
const [items, setItems] = useState(initialItems);
// items가 바뀔 때마다 콜백이 새로 만들어짐 → 자식 불필요 리렌더 유발 가능
const addItems = useCallback(
(newItems: Item[]) => {
setItems([...items, ...newItems]);
},
[items]
); // items에 의존
// items를 deps에서 빼면 오래된 items만 계속 참조
const removeItem = useCallback((id: string) => {
setItems(items.filter((item) => item.id !== id));
}, []); // 위험: 항상 초기 items 기준처럼 동작할 수 있음
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />;
}첫 번째 콜백은 items가 바뀔 때마다 다시 만들어져 자식이 불필요하게 리렌더될 수 있습니다. 두 번째는 오래된 클로저 버그로, 항상 초기 items만 본 것처럼 동작할 수 있습니다.
올바른 예 (콜백 안정, 최신 state):
function TodoList() {
const [items, setItems] = useState(initialItems);
// 빈 deps로도 최신 items 기준으로 갱신
const addItems = useCallback((newItems: Item[]) => {
setItems((curr) => [...curr, ...newItems]);
}, []);
const removeItem = useCallback((id: string) => {
setItems((curr) => curr.filter((item) => item.id !== id));
}, []);
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />;
}효과:
- 콜백 참조 안정 — state가 바뀌어도 콜백을 다시 만들 필요가 없음
- 오래된 클로저 없음 — 항상 React가 넘겨 주는 최신 이전 값으로 계산
- 의존성 단순화 — 의존성 배열이 짧아지고 실수 여지 감소
- 흔한 클로저 버그 예방
함수형 업데이트를 쓰면 좋을 때:
- 이전 state를 기준으로 다음 state를 정할 때
useCallback/useMemo안에서 state가 필요할 때- state를 읽는 이벤트 핸들러
- 비동기 작업 끝에 state를 갱신할 때
그냥 값 넣어도 될 때:
- 고정 값:
setCount(0) - props/인자에서 온 값만 반영:
setName(newName) - 이전 state와 무관하게 완전히 새 값으로 덮을 때
참고: React Compiler를 켜도 일부는 자동 최적화되지만, 정확성, stale closure 방지를 위해 함수형 업데이트는 여전히 권장됩니다.
5.12 Use Lazy State Initialization (State 지연 초기화를 사용하세요.)
- 영향도: 중간 (MEDIUM / 초기값 계산이 매 렌더마다 도는 낭비 방지)
초기 state가 무거우면 useState(() => ...)처럼 함수를 넘기는 지연 초기화를 쓰세요. 인자로 표현식만 넘기면, React는 초기값은 한 번만 쓰더라도 매 렌더마다 그 표현식을 평가합니다.
잘못된 예 (매 렌더마다 실행):
function FilteredList({ items }: { items: Item[] }) {
// 초기화 이후에도 매 렌더마다 buildSearchIndex(items) 호출됨
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items));
const [query, setQuery] = useState("");
// query만 바뀌어도 위 초기화 표현식은 다시 평가됨
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} />;
}올바른 예 (첫 마운트에서만 실행):
function FilteredList({ items }: { items: Item[] }) {
// 초기 렌더에서만 buildSearchIndex(items) 실행
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items));
const [query, setQuery] = useState("");
return <SearchResults index={searchIndex} query={query} />;
}
function UserProfile() {
// 첫 마운트에서만 localStorage 읽기, parse
const [settings, setSettings] = useState(() => {
const stored = localStorage.getItem("settings");
return stored ? JSON.parse(stored) : {};
});
return <SettingsForm settings={settings} onChange={setSettings} />;
}지연 초기화를 쓰기 좋을 때: localStorage/sessionStorage에서 초기값 계산, 인덱스/맵 같은 구조 만들기, DOM 읽기, 복잡한 변환 작업
함수 형태가 굳이 필요 없을 때: useState(0) 같은 단순 원시값, useState(props.value)처럼 이미 준비된 참조, useState({})처럼 가벼운 리터럴.
5.13 Use Transitions for Non-Urgent Updates (급하지 않은 업데이트에는 Transition을 사용하세요.)
- 영향도: 중간 (MEDIUM / UI 반응성 유지)
자주 일어나고 사용자가 즉시 느껴야 할 필요는 없는 state 갱신은 startTransition으로 감싸 전환(transition) 업데이트로 표시하세요. React가 더 긴급한 업데이트(입력, 클릭 등)를 먼저 처리해 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);
}, []);
}올바른 예 (낮은 우선순위로 state 갱신):
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);
}, []);
}5.14 Use useDeferredValue for Expensive Derived Renders (고비용 파생 렌더링에는 useDeferredValue를 사용하세요.)
- 영향도: 중간 (MEDIUM / 무거운 연산 중에도 입력에 대한 반응성 유지)
입력이 바뀔 때마다 무거운 계산이나 큰 리스트 렌더가 따라붙으면 useDeferredValue를 쓰세요. 실제 입력값(query)은 바로 반영하고, 리스트에 넘기는 값은 deferredQuery처럼 한 박자 늦은 값을 쓰면 React가 입력 업데이트를 먼저 처리하고, 여유가 생기면 무거운 결과를 그립니다.
잘못된 예 (필터가 입력 속도를 따라가려다 입력이 끊김):
function Search({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
// query가 바뀔 때마다 즉시 전체 필터, 자식 렌더
const filtered = items.filter((item) => fuzzyMatch(item, query));
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList results={filtered} />
</>
);
}올바른 예 (입력은 즉시, 결과는 준비 되는대로 표시):
function Search({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const filtered = useMemo(
() => items.filter((item) => fuzzyMatch(item, deferredQuery)),
[items, deferredQuery]
);
const isStale = query !== deferredQuery;
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<ResultsList results={filtered} />
</div>
</>
);
}쓰기 좋을 때:
- 대규묘 목록 필터/검색
- 입력에 반응하는 차트/그래프 등 무거운 시각화
- 파생 결과 때문에 렌더가 눈에 띄게 느려질 때
주의: 비싼 계산은 useMemo로 감싸고 의존성에 deferred 값을 넣으세요. 그렇지 않으면 매 렌더마다 그대로 실행됩니다.
5.15 Use useRef for Transient Values (일시적인 값은 useRef를 사용하세요.)
- 영향도: 중간 (MEDIUM / 잦은 업데이트시 불필요한 재렌더링 방지)
값이 자주 변경되지만 매번 업데이트될 때마다 다시 렌더링하고 싶지 않은 경우(예: 마우스 트래커, 인터벌, 임시 플래그)에는 useState 대신 useRef에 두세요. 컴포넌트 상태는 UI에 사용하고, 일시적으로 DOM에 인접한 값에는 ref를 사용하세요. ref를 바꿔도 리렌더는 발생하지 않습니다.
잘못된 예 (이동할 때마다 리렌더):
function Tracker() {
const [lastX, setLastX] = useState(0);
useEffect(() => {
const onMove = (e: MouseEvent) => setLastX(e.clientX);
window.addEventListener("mousemove", onMove);
return () => window.removeEventListener("mousemove", onMove);
}, []);
return (
<div
style={{
position: "fixed",
top: 0,
left: lastX,
width: 8,
height: 8,
background: "black",
}}
/>
);
}올바른 예 (추적만 하고 React 리렌더는 없음, DOM은 직접 갱신):
function Tracker() {
const lastXRef = useRef(0);
const dotRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onMove = (e: MouseEvent) => {
lastXRef.current = e.clientX; // 리렌더 없이 최신값만 보관
const node = dotRef.current;
if (node) {
// transform만 바꿔 픽셀 이동 (React state 경유 없음)
node.style.transform = `translateX(${e.clientX}px)`;
}
};
window.addEventListener("mousemove", onMove);
return () => window.removeEventListener("mousemove", onMove);
}, []);
return (
<div
ref={dotRef}
style={{
position: "fixed",
top: 0,
left: 0,
width: 8,
height: 8,
background: "black",
transform: "translateX(0px)",
}}
/>
);
}