Prologue: 5단계 Props 지옥
첫 React 프로젝트 때 컴포넌트 트리를 5단계로 쪼갰다. "재사용성이 좋다"는 말에 현혹돼서 Header, UserSection, ProfileCard, AvatarWrapper, UserAvatar까지. 깔끔한 구조라고 착각했는데, 유저 정보를 맨 위에서 맨 아래로 전달하려니 props를 5번 연속으로 내려야 했다.
<Header user={user} />
<UserSection user={user} />
<ProfileCard user={user} />
<AvatarWrapper user={user} />
<UserAvatar user={user} />
중간 3개 컴포넌트는 user를 쓰지도 않는데 그냥 전달만 한다. 나중에 user에 theme 정보를 추가하려니까 5개 파일을 다 뜯어고쳐야 했다. 이게 Props Drilling이었다.
The Struggle: Context API로 도망쳤다가
React 공식 문서를 보니 Context API가 있었다. "전역 상태"를 만들어서 어디서든 꺼내 쓸 수 있다고. Provider로 감싸고 useContext로 꺼내면 끝. 마법 같았다.
const UserContext = createContext();
function App() {
const [user, setUser] = useState({ name: "John", theme: "dark" });
return (
<UserContext.Provider value={{ user, setUser }}>
<Header />
</UserContext.Provider>
);
}
function UserAvatar() {
const { user } = useContext(UserContext);
return <img src={user.avatar} />;
}
Props Drilling은 사라졌다. 근데 문제가 생겼다. user 안에 있는 theme만 바꿨는데, user를 쓰는 모든 컴포넌트가 다 리렌더링됐다. Header도, Sidebar도, Footer도. 심지어 user.name만 쓰는 컴포넌트도. Context 값이 바뀌면 Provider 아래 전체가 리렌더링되는 구조였다.
성능 최적화를 해보려고 useMemo, React.memo를 온갖 곳에 붙였는데, 코드가 더러워지기만 했다. Context를 여러 개로 쪼개는 방법도 있었지만, 그럼 또 Provider 지옥이 시작된다.
<UserContext.Provider>
<ThemeContext.Provider>
<CartContext.Provider>
<NotificationContext.Provider>
<App />
뭔가 잘못됐다는 생각이 들었다.
Aha Moment: 상태는 "누가 쓰는가"로 분리해야 한다
어느 날 시니어 개발자가 코드 리뷰에서 한 마디 했다. "상태를 전부 전역으로 올리면 안 돼. 로컬 상태, 전역 상태, 서버 상태를 구분해야 해."
세 종류로 나눈다는 게 신선했다.
- 로컬 상태 (Local State): 한 컴포넌트 안에서만 쓰는 상태. 모달 열림/닫힘, input 값. useState면 충분.
- 전역 상태 (Global State): 여러 컴포넌트가 공유하는 UI 상태. 다크모드, 로그인 유저 정보, 장바구니. Redux, Zustand.
- 서버 상태 (Server State): 서버에서 가져온 데이터. 캐싱, 재요청, 동기화 문제. React Query.
내가 Context API에 때려박은 건 전역 상태와 서버 상태가 섞여 있었다. 분리하고 나니 각자 맞는 도구를 쓸 수 있었다.
상태 관리 도구들의 진화 제대로 이해하기
1. useState: 로컬 상태의 기본
컴포넌트 안에서만 쓰는 상태는 useState가 최적이다. 가볍고 빠르고 간단하다.
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
문제는 이 상태를 다른 컴포넌트와 공유하려는 순간 시작된다. 상태를 부모로 올리고 (Lifting State Up), props로 내려주고. 3단계만 넘어가도 지옥이다.
2. Context API: 내장 도구의 한계
React가 기본 제공하는 전역 상태 관리 도구. Provider/Consumer 패턴.
장점:
- 설치 불필요
- 간단한 테마, 언어 설정 같은 건 완벽
치명적 단점:
- Context 값이 바뀌면 Provider 아래 모든 Consumer가 리렌더링
- 상태를 쪼개면 Provider 중첩 지옥
- 성능 최적화가 수동 작업 (memo, useMemo 남발)
3. Redux: 엔터프라이즈 표준의 무게
Redux는 "상태 관리의 정석"으로 불린다. 하지만 그 대가로 엄청난 보일러플레이트를 요구한다.
아키텍처:
- Store: 전역 상태를 담는 단일 객체 (Single Source of Truth)
- Action: 상태를 바꾸겠다는 "명령서" (type과 payload를 가진 객체)
- Reducer: 순수 함수. 이전 상태 + 액션 → 새 상태
- Dispatch: 액션을 Store에 보내는 함수
Redux Toolkit (RTK)이 나와서 코드가 많이 줄었지만, 여전히 개념이 많고 무겁다. 아주 복잡한 상태 로직이 아니라면 과한 선택일 수 있다.
4. Zustand: 단순함의 승리
Zustand는 독일어로 "상태"라는 뜻이다. Redux의 철학을 가져오되, 보일러플레이트를 최소화했다.
핵심 특징:
- Provider 불필요
- Action, Reducer 분리 없음
- 훅 기반 API
- 자동 최적화 (selector로 구독한 부분만 리렌더링)
- 매우 가벼움 (1KB)
Zustand 스토어 생성:
import { create } from "zustand";
const useStore = create((set) => ({
user: { name: "John", age: 25 },
increaseAge: () => set((state) => ({ user: { ...state.user, age: state.user.age + 1 } })),
}));
// 사용
function Profile() {
// selector를 쓰면 age가 변할 때만 리렌더링!
const age = useStore((state) => state.user.age);
return <div>{age}</div>;
}
Context API와 달리, selector를 사용하면 내가구독한 데이터가 변할 때만 리렌더링된다. 이게 가장 큰 장점이다.
5. 서버 상태는 별개다: React Query
API에서 가져온 데이터는 "우리가 소유한 상태"가 아니다. 서버의 데이터를 잠깐 빌려온 거다. 그래서 다른 도구가 필요하다.
React Query (TanStack Query) 예제:
const { data, isLoading } = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
staleTime: 5000,
});
Redux에 API 데이터를 넣으면 캐싱, 재요청, 로딩 상태 관리 등을 전부 직접 짜야 한다. React Query는 이걸 자동화한다.
Application: 실제 의사결정 트리
어떤 상태 관리 도구를 쓸지 고민될 때 이 흐름도를 따라간다.
시작
│
├─ 서버에서 가져온 데이터인가?
│ └─ YES → React Query / TanStack Query
│
├─ 한 컴포넌트 안에서만 쓰는가?
│ └─ YES → useState
│
├─ 2~3개 컴포넌트가 공유하는가?
│ └─ YES → 상태를 부모로 올리고 props로 전달
│
├─ 여러 곳에서 쓰지만 거의 안 바뀌는가? (테마, 언어)
│ └─ YES → Context API
│
├─ 자주 바뀌고 여러 컴포넌트가 구독하는가?
│ ├─ 팀이 Redux에 익숙한가? → Redux Toolkit
│ ├─ 간단하게 시작하고 싶은가? → Zustand
│ └─ 상태 간 의존성이 복잡한가? → Jotai / Recoil
│
└─ 대규모 엔터프라이즈 + 상태 히스토리 추적 필요
└─ Redux + Redux DevTools
Prologue: The 5-Layer Props Nightmare
My first React project had a component tree 5 levels deep. Header → UserSection → ProfileCard → AvatarWrapper → UserAvatar. I thought it was "clean architecture" until I needed to pass user data from top to bottom. Five times. Through components that didn't even use the data.
<Header user={user} />
<UserSection user={user} />
<ProfileCard user={user} />
<AvatarWrapper user={user} />
<UserAvatar user={user} />
When I added a theme property to user, I had to modify all five files. That's when I learned what Props Drilling really meant.
The Struggle: Context API Didn't Save Me
I found Context API in the React docs. "Global state without props!" sounded magical. Wrap with Provider, extract with useContext, done.
const UserContext = createContext();
function App() {
const [user, setUser] = useState({ name: "John", theme: "dark" });
return (
<UserContext.Provider value={{ user, setUser }}>
<Header />
</UserContext.Provider>
);
}
Props Drilling vanished. But I traded one problem for another. When I changed theme, every component using that Context re-rendered. Header, Sidebar, Footer. Even components only reading user.name. Context value changes trigger re-renders of all consumers.
I plastered useMemo and React.memo everywhere trying to optimize. Code got uglier. Splitting Context into multiple pieces led to Provider Hell.
<UserContext.Provider>
<ThemeContext.Provider>
<CartContext.Provider>
<NotificationContext.Provider>
<App />
Something was fundamentally wrong.
Aha Moment: State Isn't One Thing
A senior developer dropped this in code review: "Don't lift everything to global state. Separate local state, global state, and server state."
That distinction changed everything.
- Local State: Compontent-scoped. Modal open/close, input values.
useStateis perfect. - Global State: Shared UI state. Dark mode, logged-in user, shopping cart. Redux, Zustand.
- Server State: Data from APIs. Caching, refetching, synchronization. React Query.
I'd been cramming global state and server state into Context. Once I separated them, I could use the right tool for each.
Deep Dive: Evolution of State Management
1. useState: The Foundation
For component-scoped state, useState is optimal. Lightweight, fast, simple.
The problem starts when you need to share this state. Lift to parent, pass as props. Three levels down and you're in hell.
2. Context API: Built-in But Limited
React's native global state solution. Provider/Consumer pattern.
Pros:
- No installation
- Perfect for theme, locale settings (infrequently changed)
Critical Flaw:
- Changing Context value re-renders all consumers.
- Splitting Context creates Provider nesting nightmare.
- Manual optimization with memo/useMemo is painful.
3. Redux: Enterprise Standard's Weight
Redux is the "textbook solution". Store, Action, Reducer, Dispatch. It enforces a strict unidirectional data flow, which makes debugging easy (Time Travel Debugging!).
But the boilerplate...
You need to write 3-4 files just to add a counter.
Redux Toolkit (RTK) reduced this significantly with createSlice, but it still feels "heavy" for typical apps. It introduces many concepts (Thunk, Middleware, Immutability) that might be overkill.
4. Zustand: Simplicity Wins
Zustand (German for "State") takes Redux's philosophy but removes the boilerplate.
Core Features:
- No Provider needed (No wrapper hell!)
- No separate actions/reducers
- Hook-based API
- Automatic Optimization: Components only re-render if the specific slice they select changes.
import { create } from "zustand";
const useStore = create((set) => ({
user: { name: "John", age: 25 },
increaseAge: () => set((state) => ({ user: { ...state.user, age: state.user.age + 1 } })),
}));
function Profile() {
// This component ONLY re-renders when age changes
const age = useStore((state) => state.user.age);
return <div>{age}</div>;
}
This automatic selector optimization is why I prefer Zustand over Context API for complex global state.
5. Server State: React Query
Data from APIs isn't "Your State". It's a snapshot of the server's state. Using Redux/Zustand for this is a bad idea because you have to manually handle loading, error, caching, and re-fetching.
React Query (TanStack Query) automates all of this. It keeps your client state synced with the server with zero effort.
The Forgotten Pattern: Component Composition
Before you reach for Context or Redux to fix Prop Drilling, consider Component Composition.
Instead of:
<Page user={user} />
<Layout user={user} />
<Header user={user} />
<Avatar user={user} />
Do this:
function Page({ user }) {
return (
<Layout>
<Header>
<Avatar user={user} />
</Header>
</Layout>
);
}
By passing Avatar as a child to Header, Header doesn't need to know about user props at all.
This simple pattern solves 80% of Prop Drilling cases without adding any libraries.
Conclusion: Use the Right Tool
State management isn't about "Which library is the best?". It's about "What kind of state is this?".
- Is it Server Data? -> Use React Query.
- Is it UI State used in one place? -> Use useState.
- Is it UI State used in many places?
- Simple/Static? (Theme) -> Context API.
- Complex/Dynamic? -> Zustand (or Redux for huge teams).
- Can I solve it with Composition? -> Do that first!
I used to start every project by installing Redux. Now I start with just useState and React Query. I only add Zustand when I truly feel the pain of Prop Drilling.
That is the path to a clean, maintainable, and happy codebase.
6. The Future: React Server Components & Server Actions
The landscape is changing again with Next.js App Router. With Server Components, we don't need to fetch data on the client. We just query the DB directly in the component.
// Server Component
async function UserProfile({ id }) {
const user = await db.user.findUnique({ where: { id } });
return <div>{user.name}</div>;
}
This removes the need for Client-side Server State Management (React Query) for GET requests. And Server Actions handle mutations without API routes.
The need for global client state (Redux/Zustand) is shrinking. It's becoming exclusively for UI State (Sidebar open, optimistic updates). The future is Less State on the Client. And that's a good thing.