State Management
상태 관리 구현 패턴입니다.
목차
개요
권장 라이브러리
| 라이브러리 | 권장도 | 설명 |
|---|---|---|
| Zustand | ⭐ 기본 권장 | 간단한 API, 보일러플레이트 최소화, TypeScript 지원 |
| Context API | 단순 케이스 | Props 3단계 이상 drilling 시 |
| useReducer | 복잡한 로직 | 단일 컴포넌트 내 복잡한 상태 |
사용 사례
- 전역 사용자 인증 상태
- UI 테마/레이아웃 설정
- 폼 상태 관리
- 복잡한 비즈니스 로직
사용하지 말아야 할 때
- 단일 컴포넌트 로컬 상태 →
useState - 서버 상태 → React Query
- URL 상태 → React Router
Zustand 기본 사용법
설치
pnpm add zustandStore 생성
// stores/auth.ts
import { create } from "zustand"
interface User {
id: string
name: string
email: string
}
interface AuthStore {
user: User | null
isAuthenticated: boolean
login: (user: User) => void
logout: () => void
}
export const useAuthStore = create<AuthStore>((set) => ({
user: null,
isAuthenticated: false,
login: (user) => set({ user, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
}))컴포넌트에서 사용
import { useAuthStore } from "@/stores/auth"
export function ProfilePage() {
const { user, logout } = useAuthStore()
return (
<div className="p-4">
<h1>프로필</h1>
<p>{user?.name}</p>
<button onClick={logout}>로그아웃</button>
</div>
)
}Selector로 성능 최적화
// 전체 store 구독 (비권장)
const { user } = useAuthStore()
// 필요한 값만 구독 (권장)
const user = useAuthStore((state) => state.user)
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)실전 예제
Showcase Store (icignal-vite-demo)
📁
examples/icignal-vite-demo/src/stores/showcase.ts참조
// stores/showcase.ts
import { create } from "zustand"
import { persist } from "zustand/middleware"
export type Theme = "blue" | "cyan" | "purple" | "green" | "rose"
type OverlayConfig = {
type: "center" | "bottomsheet" | "fullscreen" | "sidepanel"
dim: boolean
blockInteraction: boolean
toastPosition:
| "top-left"
| "top-center"
| "top-right"
| "bottom-left"
| "bottom-center"
| "bottom-right"
}
type State = {
layout: "default" | "top-nav"
theme: Theme
colorMode?: string
overlayConfig: OverlayConfig
}
type Action = {
setLayout: (layout: "default" | "top-nav") => void
setTheme: (theme: Theme) => void
setColorMode: (colorMode?: string) => void
setOverlayConfig: (config: OverlayConfig) => void
}
export const useShowcase = create<State & Action>()(
persist(
(set) => ({
layout: "default",
theme: "blue",
colorMode: "light",
overlayConfig: {
type: "center",
dim: true,
blockInteraction: true,
toastPosition: "top-right",
},
setLayout: (layout) => set({ layout }),
setTheme: (theme) => set({ theme }),
setColorMode: (colorMode) => set({ colorMode }),
setOverlayConfig: (overlayConfig) => set({ overlayConfig }),
}),
{ name: "showcase-storage" },
),
)파생 훅 패턴
Store의 특정 부분만 사용하는 커스텀 훅으로 관심사를 분리합니다.
// 오버레이 설정만 사용하는 훅
export const useOverlayConfig = () => {
const config = useShowcase((state) => state.overlayConfig)
const setConfig = useShowcase((state) => state.setOverlayConfig)
return { config, setConfig }
}
// 테마만 사용하는 훅
export const useThemeConfig = () => {
const theme = useShowcase((state) => state.theme)
const colorMode = useShowcase((state) => state.colorMode)
const setTheme = useShowcase((state) => state.setTheme)
const setColorMode = useShowcase((state) => state.setColorMode)
return { theme, colorMode, setTheme, setColorMode }
}컴포넌트에서 사용
import { useOverlayConfig } from "@/stores/showcase"
export function SettingsPanel() {
const { config, setConfig } = useOverlayConfig()
return (
<div className="space-y-4">
<select
value={config.type}
onChange={(e) =>
setConfig({
...config,
type: e.target.value as OverlayConfig["type"],
})
}
>
<option value="center">Center</option>
<option value="fullscreen">Fullscreen</option>
<option value="bottomsheet">Bottom Sheet</option>
</select>
<label>
<input
type="checkbox"
checked={config.dim}
onChange={(e) => setConfig({ ...config, dim: e.target.checked })}
/>
Dim 배경
</label>
</div>
)
}Zustand 미들웨어
persist (localStorage 저장)
import { create } from "zustand"
import { persist } from "zustand/middleware"
export const useSettingsStore = create(
persist(
(set) => ({
theme: "light",
setTheme: (theme) => set({ theme }),
}),
{ name: "settings-storage" }, // localStorage key
),
)immer (불변성 자동 관리)
pnpm add immerimport { create } from "zustand"
import { immer } from "zustand/middleware/immer"
interface Todo {
id: string
text: string
completed: boolean
}
export const useTodoStore = create(
immer<{ todos: Todo[]; addTodo: (text: string) => void }>((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
state.todos.push({ id: Date.now().toString(), text, completed: false })
}),
})),
)Best Practices
✅ 권장 사항
| 영역 | 권장 사항 |
|---|---|
| Selector | 필요한 값만 구독하여 리렌더링 최소화 |
| 파생 훅 | 관심사별로 store 분리 |
| persist | 사용자 설정 등 영속 필요 데이터에 사용 |
| 타입 | State와 Action 타입 분리 정의 |
⚠️ 피해야 할 것
- 모든 상태를 전역으로 관리
- 서버 상태를 Zustand로 관리 (→ React Query 사용)
- 전체 store 구독 (
const { user } = useStore())
대안적 방법
Zustand 외에도 다음 방법들을 사용할 수 있습니다.
Context API (단순 케이스)
import { createContext, useContext, useState, ReactNode } from "react"
const ThemeContext = createContext<{
theme: string
toggle: () => void
} | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState("light")
const toggle = () => setTheme(theme === "light" ? "dark" : "light")
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
)
}
export const useTheme = () => {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error("useTheme must be used within ThemeProvider")
return ctx
}사용 시점: Props drilling 3단계 이상, 단순한 전역 상태
useReducer (복잡한 로컬 상태)
import { useReducer } from "react"
type State = { count: number }
type Action = { type: "increment" } | { type: "decrement" }
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { count: state.count + 1 }
case "decrement":
return { count: state.count - 1 }
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 })
return (
<div>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
</div>
)
}사용 시점: 단일 컴포넌트 내 복잡한 상태 로직
관련 패턴
- Data Fetching - 서버 상태 관리 (React Query)
- Authentication - 인증 상태 관리
Last updated on