| name | state-management |
| description | Zustand v5 전역 상태관리, TanStack Query v5 서버 상태/캐싱 전략, 상태 레이어 분리 아키텍처 |
상태 관리 패턴 (Zustand v5 + TanStack Query v5)
소스: https://zustand.docs.pmnd.rs | https://tanstack.com/query/v5/docs
검증일: 2026-08-26 (최초 2026-06-20 · 08-26 freshness 재검증: Zustand 5.0.15·TanStack Query 5.102 최신, API 변경 없음. v4→v5 전환 상세는 frontend/tanstack-query-v4-to-v5-migration이 정본)
상태 분류 — 무엇으로 관리할지 판단 기준
상태 종류?
├─ 서버에서 오는 데이터 (API 응답, 캐싱 필요)
│ └─ TanStack Query (useQuery, useMutation)
│
├─ 클라이언트 전역 상태 (여러 컴포넌트 공유, 서버 무관)
│ └─ Zustand
│
└─ 특정 컴포넌트 내 지역 상태
└─ useState / useReducer
| 상태 유형 | 도구 | 예시 |
|---|
| 서버 데이터 | TanStack Query | 유저 목록, 게시글, 프로필 |
| 전역 UI 상태 | Zustand | 사이드바 열림/닫힘, 선택된 탭, 모달 |
| 인증 상태 | Zustand | 로그인 유저 정보, 토큰 |
| 폼 상태 | React Hook Form | 폼 입력값, 유효성 |
| 지역 상태 | useState | 버튼 hover, 토글 |
❌ 피해야 할 패턴: 서버 데이터를 Zustand에 저장 → TanStack Query가 캐싱/동기화를 더 잘 처리함
Zustand v5
기본 스토어 생성
import { create } from 'zustand'
interface SidebarStore {
isOpen: boolean
open: () => void
close: () => void
toggle: () => void
}
export const useSidebarStore = create<SidebarStore>((set) => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
}))
Zustand v4 → v5 주요 변경사항
import create from 'zustand'
const useStore = create(...)
import { create } from 'zustand'
const useStore = create(...)
import { useShallow } from 'zustand/react/shallow'
const { open, close } = useStore(useShallow((s) => ({ open: s.open, close: s.close })))
슬라이스 패턴 (스토어 분리)
import { StateCreator } from 'zustand'
export interface AuthSlice {
user: User | null
setUser: (user: User | null) => void
clearUser: () => void
}
export const createAuthSlice: StateCreator<AuthSlice> = (set) => ({
user: null,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
})
export interface UISlice {
sidebarOpen: boolean
toggleSidebar: () => void
}
export const createUISlice: StateCreator<UISlice> = (set) => ({
: ,
: ( ({ : !s. })),
})
{ create }
{ createAuthSlice, }
{ createUISlice, }
= &
useStore = create<>( ({
...(...args),
...(...args),
}))
미들웨어: devtools + persist
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
interface SettingsStore {
theme: 'light' | 'dark'
language: string
setTheme: (theme: 'light' | 'dark') => void
}
export const useSettingsStore = create<SettingsStore>()(
devtools(
persist(
(set) => ({
theme: 'light',
language: 'ko',
setTheme: (theme) => set({ theme }),
}),
{
name: 'settings-storage',
partialize: (state) => ({
theme: state.theme,
language: state.language,
}),
}
),
{ name: 'SettingsStore' }
)
)
선택적 구독 (리렌더링 최적화)
const store = useStore()
const user = useStore((s) => s.user)
const isOpen = useSidebarStore((s) => s.isOpen)
import { useShallow } from 'zustand/react/shallow'
const { open, close } = useStore(
useShallow((s) => ({ open: s.open, close: s.close }))
)
TanStack Query v5
기본 설정 (Next.js App Router)
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
)
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
TanStack Query v4 → v5 주요 변경사항
const { data, isLoading } = useQuery(['users'], fetchUsers)
const { data } = useQuery(['user', id], () => fetchUser(id), {
onSuccess: (data) => console.log(data),
onError: (err) => console.error(err),
})
const { data, isLoading } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
const { data } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})
Query Key 관리 패턴
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilter) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
}
useQuery({ queryKey: userKeys.detail(userId), queryFn: () => fetchUser(userId) })
queryClient.invalidateQueries({ queryKey: userKeys.all })
queryClient.invalidateQueries({ queryKey: userKeys.lists() })
useQuery 핵심 옵션
const { data, isPending, isError, error, isFetching, isStale } = useQuery({
queryKey: ['posts', filters],
queryFn: () => fetchPosts(filters),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
enabled: !!userId,
placeholderData: keepPreviousData,
select: (data) => data.items,
refetchInterval: 30 * 1000,
})
useMutation + 캐시 업데이트
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { postKeys } from './post.keys'
export function useCreatePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreatePostInput) => createPost(data),
onSuccess: (newPost) => {
queryClient.invalidateQueries({ queryKey: postKeys.lists() })
},
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: postKeys.lists() })
const previous = queryClient.getQueryData(postKeys.lists())
queryClient.setQueryData(postKeys.lists(), (old: Post[]) => [
...old,
{ ...newData, id: 'temp', createdAt: new Date() },
])
{ previous }
},
: {
queryClient.(postKeys.(), context?.)
},
: {
queryClient.({ : postKeys.() })
},
})
}
무한 스크롤 (useInfiniteQuery)
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, limit: 20 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
const posts = data?.pages.flatMap((page) => page.items) ?? []
Next.js App Router + Prefetching (SSR)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
async function PostsPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: postKeys.lists(),
queryFn: fetchPosts,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList /> {/* Client Component에서 useQuery → 캐시 히트 */}
</HydrationBoundary>
)
}
Zustand + TanStack Query 조합 패턴
const useModalStore = create<ModalStore>(...)
const useAuthStore = create<AuthStore>(...)
const { data: posts } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts })
const selectedUserId = useStore((s) => s.selectedUserId)
const { data: user } = useQuery({
queryKey: userKeys.detail(selectedUserId),
queryFn: () => fetchUser(selectedUserId),
enabled: !!selectedUserId,
})
const usePostStore = create((set) => ({
posts: [],
fetchPosts: async () => {
const data = await api.getPosts()
set({ posts: data })
},
}))
스토어 파일 구조
src/
└── store/
├── index.ts # 통합 스토어 export
├── slices/
│ ├── auth.ts # 인증 슬라이스
│ ├── ui.ts # UI 상태 슬라이스
│ └── settings.ts # 설정 슬라이스
└── queries/
├── user.keys.ts # 쿼리 키 팩토리
├── user.queries.ts # useQuery 훅
├── user.mutations.ts # useMutation 훅
└── post.keys.ts
흔한 실수 패턴
const queryClient = new QueryClient()
const [queryClient] = useState(() => new QueryClient())
useQuery({ queryKey: ['x'], queryFn: fn, onSuccess: cb })
const { data } = useQuery(...)
useEffect(() => { if (data) doSomething(data) }, [data])
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId!),
enabled: !!userId,
})