Skip to content

Split View: 상태 관리 완전 비교 2025: React Context vs Zustand vs Jotai vs Redux Toolkit vs Recoil

✨ Learn with Quiz
|

상태 관리 완전 비교 2025: React Context vs Zustand vs Jotai vs Redux Toolkit vs Recoil

1. 2025년 상태 관리 지형도

npm 다운로드 트렌드

2025년 React 생태계의 상태 관리 라이브러리 경쟁은 그 어느 때보다 치열합니다. 주간 다운로드 수 기준으로 보면 놀라운 변화가 일어났습니다.

라이브러리주간 다운로드 (2025)번들 크기첫 릴리스
Redux (+ Toolkit)~9M11KB (RTK)2015
Zustand~5.5M1.1KB2019
TanStack Query~4.5M13KB2020
Jotai~2.2M3.4KB2020
Recoil~500K21KB2020

패러다임의 전환

2025년 가장 중요한 변화는 서버 상태와 클라이언트 상태의 분리입니다. 과거에는 Redux 하나로 모든 상태를 관리했지만, 이제는 명확한 역할 분담이 이루어집니다.

  • 서버 상태: TanStack Query / SWR (API 데이터, 캐싱, 동기화)
  • 클라이언트 상태: Zustand / Jotai / Redux Toolkit (UI 상태, 폼 상태, 테마)
  • URL 상태: nuqs / next-usequerystate (검색 필터, 페이지네이션)
  • 폼 상태: React Hook Form / Formik (입력 값, 유효성 검사)
┌─────────────────────────────────────────────────┐
Application State├──────────────┬──────────────┬───────────────────┤
Server StateClient StateURL / Form StateTanStack QZustand    │  nuqs + RHFSWRJotai      │                   │
│              │   Redux TK   │                   │
└──────────────┴──────────────┴───────────────────┘

2. 서버 상태 혁명: TanStack Query와 SWR

왜 서버 상태를 분리하는가?

전통적인 Redux 패턴에서는 API 호출 결과를 전역 스토어에 저장했습니다. 그러나 이 접근법에는 심각한 문제들이 있습니다.

  • 캐시 무효화: 언제 데이터를 다시 가져올지 수동으로 관리해야 합니다
  • 로딩/에러 상태: 매번 isLoading, error 상태를 직접 관리합니다
  • 중복 요청: 같은 데이터를 여러 컴포넌트에서 요청하면 중복 API 호출이 발생합니다
  • 낙관적 업데이트: 구현이 매우 복잡합니다

TanStack Query (React Query v5)

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

// 서버 상태 조회 - 캐싱, 재검증 자동 처리
function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(res => res.json()),
    staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
    gcTime: 30 * 60 * 1000,   // 30분 후 가비지 컬렉션
  })
}

// 서버 상태 변경 - 낙관적 업데이트 포함
function useCreateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (newUser: User) =>
      fetch('/api/users', {
        method: 'POST',
        body: JSON.stringify(newUser),
      }).then(res => res.json()),

    // 낙관적 업데이트
    onMutate: async (newUser) => {
      await queryClient.cancelQueries({ queryKey: ['users'] })
      const previous = queryClient.getQueryData(['users'])
      queryClient.setQueryData(['users'], (old: User[]) => [...old, newUser])
      return { previous }
    },
    onError: (err, newUser, context) => {
      queryClient.setQueryData(['users'], context?.previous)
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] })
    },
  })
}

Stale-While-Revalidate 패턴

사용자 요청 → 캐시 확인 → 캐시 데이터 즉시 반환 (stale)
              백그라운드에서 새 데이터 가져오기 (revalidate)
              새 데이터로 UI 업데이트

이 패턴 덕분에 사용자는 항상 즉각적인 응답을 받으면서도, 최신 데이터를 자동으로 받게 됩니다.

SWR vs TanStack Query 비교

기능TanStack Query v5SWR v2
번들 크기13KB4.2KB
Mutation 지원useMutation 내장별도 구현 필요
낙관적 업데이트1등급 지원수동 구현
Devtools전용 DevTools없음
무한 스크롤useInfiniteQueryuseSWRInfinite
SSR 지원Hydration APINext.js 통합

3. React Context: 언제 충분한가

Context가 적합한 경우

React Context는 별도 라이브러리 없이 사용할 수 있는 내장 상태 공유 메커니즘입니다.

// 테마, 인증, 로케일 등 변경이 드문 상태에 적합
const ThemeContext = createContext<ThemeContextType | null>(null)

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')

  const value = useMemo(() => ({ theme, setTheme }), [theme])

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  )
}

function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) throw new Error('useTheme must be used within ThemeProvider')
  return context
}

Context의 성능 함정

Context의 가장 큰 문제는 value가 변경되면 모든 소비자가 리렌더링된다는 것입니다.

// 나쁜 패턴: 하나의 Context에 모든 상태
const AppContext = createContext({
  user: null,
  theme: 'light',
  notifications: [],
  sidebar: false,
})

// 문제: sidebar만 변경해도 user, theme, notifications를 사용하는
// 모든 컴포넌트가 리렌더링됩니다!

Composition Pattern으로 해결

// 좋은 패턴: 상태별로 Context 분리
function App() {
  return (
    <ThemeProvider>
      <AuthProvider>
        <NotificationProvider>
          <SidebarProvider>
            <Layout />
          </SidebarProvider>
        </NotificationProvider>
      </AuthProvider>
    </ThemeProvider>
  )
}

// 각 Context는 자신의 상태만 관리하므로
// 불필요한 리렌더링이 발생하지 않습니다

Context가 부족한 경우

다음 상황에서는 전용 상태 관리 라이브러리가 필요합니다.

  • 상태가 자주 변경될 때 (카운터, 타이머, 실시간 데이터)
  • 상태를 세밀하게 선택해서 구독해야 할 때
  • 미들웨어가 필요할 때 (로깅, 영속화, 개발자 도구)
  • 상태 변경 로직이 복잡할 때

4. Redux Toolkit: 아직도 필요한가?

RTK의 현재 위치

Redux Toolkit(RTK)은 Redux의 보일러플레이트 문제를 해결하고, 공식 권장 방식이 되었습니다. 2025년에도 대규모 엔터프라이즈에서 여전히 강력한 선택입니다.

createSlice: 보일러플레이트 제거

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface TodoState {
  items: Todo[]
  filter: 'all' | 'active' | 'completed'
}

const todoSlice = createSlice({
  name: 'todos',
  initialState: { items: [], filter: 'all' } as TodoState,
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      // Immer가 내장되어 있어 불변성을 신경 쓰지 않아도 됩니다
      state.items.push({
        id: crypto.randomUUID(),
        text: action.payload,
        completed: false,
      })
    },
    toggleTodo: (state, action: PayloadAction<string>) => {
      const todo = state.items.find(t => t.id === action.payload)
      if (todo) todo.completed = !todo.completed
    },
    setFilter: (state, action: PayloadAction<TodoState['filter']>) => {
      state.filter = action.payload
    },
  },
})

export const { addTodo, toggleTodo, setFilter } = todoSlice.actions
export default todoSlice.reducer

RTK Query: 내장 서버 상태 관리

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['User', 'Post'],
  endpoints: (builder) => ({
    getUsers: builder.query<User[], void>({
      query: () => '/users',
      providesTags: ['User'],
    }),
    createUser: builder.mutation<User, Partial<User>>({
      query: (body) => ({
        url: '/users',
        method: 'POST',
        body,
      }),
      invalidatesTags: ['User'],
    }),
  }),
})

export const { useGetUsersQuery, useCreateUserMutation } = api

Redux Toolkit이 여전히 필요한 경우

  • 대규모 팀: 엄격한 패턴과 컨벤션이 필요한 팀
  • 복잡한 비즈니스 로직: 여러 상태 간의 복잡한 상호작용
  • 타임 트래블 디버깅: Redux DevTools의 강력한 디버깅
  • 미들웨어 생태계: saga, thunk 등 풍부한 미들웨어 지원
  • 레거시 호환: 기존 Redux 코드베이스와의 점진적 마이그레이션

5. Zustand: 미니멀리즘의 승리

왜 Zustand인가?

Zustand는 1.1KB라는 놀라운 번들 크기로 가장 가벼운 상태 관리 라이브러리입니다. Provider가 필요 없고, 보일러플레이트가 최소한이며, TypeScript와 완벽하게 호환됩니다.

기본 스토어 패턴

import { create } from 'zustand'

interface BearStore {
  bears: number
  increase: () => void
  decrease: () => void
  reset: () => void
}

const useBearStore = create<BearStore>((set) => ({
  bears: 0,
  increase: () => set((state) => ({ bears: state.bears + 1 })),
  decrease: () => set((state) => ({ bears: state.bears - 1 })),
  reset: () => set({ bears: 0 }),
}))

// 사용: Provider 없이 어디서나 호출
function BearCounter() {
  const bears = useBearStore((state) => state.bears)
  return <span>{bears} bears</span>
}

function BearControls() {
  const increase = useBearStore((state) => state.increase)
  return <button onClick={increase}>Add bear</button>
}

미들웨어: persist, devtools, immer

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

interface AppStore {
  user: User | null
  preferences: Preferences
  setUser: (user: User | null) => void
  updatePreference: (key: string, value: unknown) => void
}

const useAppStore = create<AppStore>()(
  devtools(
    persist(
      immer((set) => ({
        user: null,
        preferences: { theme: 'light', language: 'ko' },

        setUser: (user) => set((state) => {
          state.user = user
        }),

        updatePreference: (key, value) => set((state) => {
          state.preferences[key] = value
        }),
      })),
      {
        name: 'app-storage', // localStorage 키
        partialize: (state) => ({
          preferences: state.preferences,
        }),
      }
    ),
    { name: 'AppStore' } // DevTools 이름
  )
)

슬라이스 패턴: 대규모 스토어 분리

// userSlice.ts
const createUserSlice = (set: SetState, get: GetState) => ({
  user: null,
  login: async (credentials: Credentials) => {
    const user = await authApi.login(credentials)
    set({ user })
  },
  logout: () => set({ user: null }),
})

// cartSlice.ts
const createCartSlice = (set: SetState, get: GetState) => ({
  items: [],
  addItem: (item: CartItem) =>
    set((state) => ({ items: [...state.items, item] })),
  totalPrice: () =>
    get().items.reduce((sum, item) => sum + item.price, 0),
})

// 합치기
const useStore = create((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}))

Zustand의 강점

  • 1.1KB 번들 크기 (gzipped)
  • Provider 불필요: 컴포넌트 트리 어디서나 바로 사용
  • 선택적 구독: (state) => state.bears로 필요한 상태만 구독
  • React 외부에서도 사용 가능: Vanilla JS에서도 동작
  • SSR 호환: Next.js와 완벽 호환

6. Jotai: 원자적 상태의 힘

Atom 기반 설계

Jotai는 "원자(atom)" 단위로 상태를 관리합니다. 각 atom은 독립적이며, 필요한 컴포넌트만 리렌더링됩니다.

import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'

// 기본 atom
const countAtom = atom(0)
const nameAtom = atom('Guest')

// 파생 atom (derived)
const doubleCountAtom = atom((get) => get(countAtom) * 2)

// 읽기/쓰기 atom
const countWithLogAtom = atom(
  (get) => get(countAtom),
  (get, set, newValue: number) => {
    console.log(`Count changed: ${get(countAtom)} -> ${newValue}`)
    set(countAtom, newValue)
  }
)

// 사용
function Counter() {
  const [count, setCount] = useAtom(countAtom)
  const doubleCount = useAtomValue(doubleCountAtom)

  return (
    <div>
      <p>Count: {count}, Double: {doubleCount}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  )
}

비동기 Atom과 Suspense 통합

// 비동기 atom - React Suspense와 자동 통합
const userAtom = atom(async () => {
  const response = await fetch('/api/user')
  return response.json()
})

// 의존성이 있는 비동기 atom
const userPostsAtom = atom(async (get) => {
  const user = await get(userAtom)
  const response = await fetch(`/api/users/${user.id}/posts`)
  return response.json()
})

// Suspense로 로딩 처리
function UserPosts() {
  const posts = useAtomValue(userPostsAtom)
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserPosts />
    </Suspense>
  )
}

atomFamily: 동적 atom 생성

import { atomFamily } from 'jotai/utils'

// ID별로 독립적인 atom 생성
const todoAtomFamily = atomFamily((id: string) =>
  atom({
    id,
    text: '',
    completed: false,
  })
)

// 각 Todo는 독립적인 atom으로 관리되어
// 하나의 Todo 변경이 다른 Todo의 리렌더링을 일으키지 않습니다
function TodoItem({ id }: { id: string }) {
  const [todo, setTodo] = useAtom(todoAtomFamily(id))
  return (
    <label>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => setTodo(prev => ({ ...prev, completed: !prev.completed }))}
      />
      {todo.text}
    </label>
  )
}

Jotai의 강점

  • 3.4KB 번들 크기 (gzipped)
  • 원자적 리렌더링: 변경된 atom을 구독하는 컴포넌트만 리렌더링
  • React Suspense 통합: 비동기 상태를 선언적으로 처리
  • Bottom-up 접근: 상태를 작은 단위에서 조합
  • DevTools 지원: Jotai DevTools, React DevTools 통합

7. Recoil: Meta의 선택, 그러나 쇠퇴

Recoil의 현재 상태

Recoil은 Meta(Facebook)에서 만든 원자적 상태 관리 라이브러리입니다. 그러나 2025년 현재 유지보수가 부진하고, 최근 릴리스 간격이 길어지고 있습니다.

기본 사용법

import { atom, selector, useRecoilState, useRecoilValue } from 'recoil'

const todoListAtom = atom({
  key: 'todoList',
  default: [] as Todo[],
})

const filteredTodoSelector = selector({
  key: 'filteredTodos',
  get: ({ get }) => {
    const list = get(todoListAtom)
    const filter = get(todoFilterAtom)
    switch (filter) {
      case 'completed': return list.filter(t => t.completed)
      case 'active': return list.filter(t => !t.completed)
      default: return list
    }
  },
})

// atomFamily - 동적 atom
const userAtomFamily = atomFamily({
  key: 'user',
  default: (userId: string) => async () => {
    const res = await fetch(`/api/users/${userId}`)
    return res.json()
  },
})

Recoil vs Jotai

항목RecoilJotai
번들 크기21KB3.4KB
Key 필요필수 (문자열 key)불필요
ProviderRecoilRoot 필수선택적
유지보수부진활발
SSR제한적완벽 지원
커뮤니티감소 추세증가 추세

Jotai가 Recoil의 모든 기능을 더 작은 번들로 제공하면서 활발하게 유지보수되기 때문에, 신규 프로젝트에서는 Jotai를 권장합니다.


8. Signals: 새로운 패러다임

Signals란?

Signals는 세밀한 반응성(fine-grained reactivity)을 제공하는 상태 원시값입니다. React의 재렌더링 모델과 달리, Signal이 변경되면 해당 Signal을 사용하는 DOM 부분만 직접 업데이트됩니다.

Preact Signals

import { signal, computed, effect } from '@preact/signals-react'

// Signal 생성
const count = signal(0)
const doubled = computed(() => count.value * 2)

// 컴포넌트에서 사용 - 리렌더링 없이 DOM 직접 업데이트
function Counter() {
  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubled}</p>
      <button onClick={() => count.value++}>+1</button>
    </div>
  )
}

// 부수 효과
effect(() => {
  console.log(`Count is now: ${count.value}`)
})

SolidJS Signals

import { createSignal, createMemo, createEffect } from 'solid-js'

function Counter() {
  const [count, setCount] = createSignal(0)
  const doubled = createMemo(() => count() * 2)

  createEffect(() => {
    console.log(`Count: ${count()}`)
  })

  return (
    <div>
      <p>Count: {count()}, Doubled: {doubled()}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  )
}

Angular Signals (v17+)

import { signal, computed, effect } from '@angular/core'

@Component({
  template: `
    <p>Count: {{ count() }}, Doubled: {{ doubled() }}</p>
    <button (click)="increment()">+1</button>
  `
})
class CounterComponent {
  count = signal(0)
  doubled = computed(() => this.count() * 2)

  constructor() {
    effect(() => {
      console.log(`Count: ${this.count()}`)
    })
  }

  increment() {
    this.count.update(c => c + 1)
  }
}

React와 Signals의 관계

React 팀은 공식적으로 Signals를 채택하지 않았지만, React 19의 컴파일러(React Forget)가 유사한 최적화를 자동으로 수행합니다. 즉, React는 Signals 대신 컴파일러 기반 최적화를 선택했습니다.

접근법프레임워크원리
SignalsPreact, Solid, Angular런타임에서 세밀한 반응성 추적
CompilerReact 19빌드 시 자동 메모이제이션
ProxyMobX, ValtioProxy 객체로 변경 감지

9. 종합 비교표

기능 비교

항목ContextRedux TKZustandJotaiRecoil
번들 크기0KB (내장)11KB1.1KB3.4KB21KB
보일러플레이트중간낮음 (RTK)매우 낮음매우 낮음중간
학습 곡선낮음중간낮음낮음중간
TypeScript좋음매우 좋음매우 좋음매우 좋음좋음
SSR 지원좋음좋음매우 좋음매우 좋음제한적
DevToolsReact DTRedux DTRedux DTJotai DT제한적
미들웨어없음풍부persist/devtools유틸리티없음
선택적 구독불가useSelectorselectoratom 단위selector
Provider 필요필수필수불필요선택필수
비동기 지원useEffectcreateAsyncThunk직접 구현SuspenseSuspense

성능 벤치마크 (1,000개 항목 리스트)

시나리오ContextRedux TKZustandJotai
단일 항목 업데이트12.3ms2.1ms1.8ms0.9ms
전체 리스트 업데이트15.2ms8.7ms7.9ms8.2ms
메모리 사용량기준+2.1MB+0.3MB+0.5MB
초기 렌더링45ms52ms44ms46ms

10. 프로젝트별 선택 가이드

Decision Framework

프로젝트 규모는?
├── 소규모 (1-3, 단순 UI)
│   ├── 상태가 자주 변하나요?
│   │   ├── YesZustand
│   │   └── NoReact Context
│   └── 서버 데이터가 중심인가요?
│       └── YesTanStack Query만으로 충분
├── 중규모 (4-10, 복잡한 UI)
│   ├── 많은 독립적 상태가 있나요?
│   │   ├── YesJotai
│   │   └── NoZustand
│   └── 서버 상태가 복잡한가요?
│       └── YesTanStack Query + Zustand/Jotai
└── 대규모 (10+, 엔터프라이즈)
    ├── 기존 Redux 코드가 있나요?
    │   ├── YesRedux Toolkit으로 마이그레이션
    │   └── NoZustand + TanStack Query
    └── 엄격한 아키텍처가 필요한가요?
        └── YesRedux Toolkit

시나리오별 추천

시나리오추천 조합
간단한 랜딩 페이지React Context
SaaS 대시보드Zustand + TanStack Query
E-commerceJotai + TanStack Query
소셜 미디어 앱Zustand + TanStack Query
대규모 엔터프라이즈Redux Toolkit + RTK Query
실시간 협업 도구Zustand + WebSocket 미들웨어
에디터/IDE류 도구Jotai (세밀한 상태 제어)

11. 마이그레이션 가이드

Redux에서 Zustand로

// Before: Redux Toolkit
// store.ts
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1 },
    decrement: (state) => { state.value -= 1 },
  },
})

// Component
function Counter() {
  const count = useSelector((state: RootState) => state.counter.value)
  const dispatch = useDispatch()
  return <button onClick={() => dispatch(increment())}>Count: {count}</button>
}

// After: Zustand
const useCounterStore = create<CounterStore>((set) => ({
  value: 0,
  increment: () => set((s) => ({ value: s.value + 1 })),
  decrement: () => set((s) => ({ value: s.value - 1 })),
}))

function Counter() {
  const count = useCounterStore((s) => s.value)
  const increment = useCounterStore((s) => s.increment)
  return <button onClick={increment}>Count: {count}</button>
}

Context에서 Jotai로

// Before: React Context
const CountContext = createContext({ count: 0, setCount: () => {} })

function CountProvider({ children }) {
  const [count, setCount] = useState(0)
  return (
    <CountContext.Provider value={{ count, setCount }}>
      {children}
    </CountContext.Provider>
  )
}

function Counter() {
  const { count, setCount } = useContext(CountContext)
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}

// After: Jotai
const countAtom = atom(0)

function Counter() {
  const [count, setCount] = useAtom(countAtom)
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}
// Provider 제거! 코드 50% 감소!

마이그레이션 체크리스트

  • 기존 상태를 서버/클라이언트로 분류
  • 서버 상태를 TanStack Query로 이전
  • 클라이언트 상태를 선택한 라이브러리로 이전
  • 테스트 코드 업데이트
  • 성능 벤치마크 비교
  • 점진적 롤아웃 (기능별 마이그레이션)

12. 면접 질문과 퀴즈

면접 질문 10선

Q1. 서버 상태와 클라이언트 상태의 차이점을 설명하고, 각각에 적합한 도구를 추천하세요.

서버 상태는 API에서 가져오는 비동기 데이터로 캐싱, 무효화, 동기화가 핵심입니다. TanStack Query나 SWR이 적합합니다. 클라이언트 상태는 UI 토글, 폼 입력 등 순수 프론트엔드 상태로 Zustand, Jotai, Context가 적합합니다.

Q2. React Context의 성능 문제는 무엇이며, 어떻게 해결하나요?

Context의 value가 변경되면 해당 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다. 해결 방법은 Context 분리, useMemo로 value 메모이제이션, 또는 Zustand/Jotai로 전환하는 것입니다.

Q3. Zustand와 Jotai의 핵심 차이점은 무엇인가요?

Zustand는 스토어 기반 (top-down) 접근법으로 관련 상태를 하나의 스토어에 모읍니다. Jotai는 atom 기반 (bottom-up) 접근법으로 상태를 최소 단위로 분리합니다. Zustand는 단일 스토어가 자연스러운 경우, Jotai는 많은 독립적 상태가 있는 경우 적합합니다.

Q4. Redux Toolkit이 2025년에도 유효한 이유를 설명하세요.

엄격한 패턴 강제, 타임 트래블 디버깅, 풍부한 미들웨어 생태계, RTK Query 내장, 대규모 팀에서의 코드 일관성 유지가 장점입니다.

Q5. Stale-While-Revalidate 패턴을 설명하세요.

캐시된 (stale) 데이터를 즉시 반환하여 빠른 UX를 제공하면서, 백그라운드에서 새 데이터를 가져와 (revalidate) UI를 업데이트합니다. TanStack Query와 SWR의 핵심 전략입니다.

Q6. Signals가 React의 재렌더링 모델과 다른 점은 무엇인가요?

React는 상태 변경 시 컴포넌트 함수 전체를 다시 실행하고 Virtual DOM을 비교합니다. Signals는 변경된 값을 사용하는 DOM 부분만 직접 업데이트하여 불필요한 컴포넌트 재실행을 피합니다.

Q7. Zustand의 persist 미들웨어가 SSR에서 발생시키는 문제와 해결법은?

서버에서는 localStorage가 없어 hydration mismatch가 발생합니다. skipHydration 옵션이나 useEffect에서 수동 hydration으로 해결합니다.

Q8. Jotai의 derived atom과 Recoil의 selector의 차이를 설명하세요.

기능적으로 유사하지만, Jotai는 문자열 key가 불필요하고 번들 크기가 6배 작습니다. Jotai의 atom은 참조 동일성으로 식별되어 key 충돌 위험이 없습니다.

Q9. TanStack Query의 queryKey 설계 전략은?

계층적 키 구조(예: ['users', userId, 'posts'])를 사용하여 세밀한 캐시 무효화를 가능하게 합니다. 부모 키를 무효화하면 하위 키도 자동 무효화됩니다.

Q10. 새 프로젝트에서 상태 관리 도구를 선택하는 기준은?

프로젝트 규모, 팀 경험, SSR 필요성, 번들 크기 요구사항, 상태의 복잡도를 고려합니다. 대부분의 프로젝트는 TanStack Query + Zustand 조합으로 시작하는 것이 좋습니다.

퀴즈 5문제

Q1. Zustand에서 Provider가 필요하지 않은 이유는?

Zustand는 모듈 스코프에서 스토어를 생성하여 전역 싱글톤으로 관리합니다. React 컴포넌트 트리 외부에서 상태를 관리하므로 Provider로 감쌀 필요가 없습니다. 이는 설정을 단순화하고 Provider 중첩 문제를 제거합니다.

Q2. TanStack Query의 staleTime과 gcTime의 차이는?

staleTime은 데이터가 fresh 상태를 유지하는 시간입니다. fresh 데이터는 재요청하지 않습니다. gcTime(garbage collection time)은 비활성 쿼리 데이터가 메모리에서 제거되기까지의 시간입니다. 기본값은 staleTime이 0, gcTime이 5분입니다.

Q3. Jotai의 atom이 Recoil의 atom보다 유리한 점 2가지는?

첫째, 문자열 key가 불필요합니다. Jotai atom은 JavaScript 객체 참조로 식별되어 key 충돌 걱정이 없습니다. 둘째, 번들 크기가 3.4KB로 Recoil(21KB)의 약 6분의 1입니다.

Q4. React Context의 리렌더링 문제를 useMemo만으로 완전히 해결할 수 있을까?

아닙니다. useMemo는 value 객체의 불필요한 재생성을 방지하지만, value 안의 실제 값이 변경되면 여전히 모든 소비자가 리렌더링됩니다. Context를 분리하거나 선택적 구독을 지원하는 라이브러리(Zustand, Jotai)를 사용해야 합니다.

Q5. Redux Toolkit에서 Zustand로 마이그레이션할 때 가장 주의할 점은?

Zustand에는 Redux의 미들웨어 체인과 동일한 개념이 없으므로, saga나 복잡한 비동기 플로우를 재설계해야 합니다. 또한 Redux DevTools의 타임 트래블 디버깅 기능을 Zustand에서는 제한적으로만 사용할 수 있습니다. 점진적 마이그레이션을 위해 두 라이브러리를 일시적으로 병행 운영하는 전략이 필요합니다.


References

  1. Zustand GitHub Repository
  2. Jotai Documentation
  3. Redux Toolkit Official Docs
  4. TanStack Query Documentation
  5. Recoil Documentation
  6. Preact Signals
  7. SolidJS Reactivity
  8. Angular Signals RFC
  9. SWR Documentation
  10. React Documentation - Context
  11. npm trends: State Management Libraries
  12. Theo Browne - State Management in 2025
  13. Jack Herrington - Zustand vs Jotai
  14. TkDodo - Practical React Query

State Management Complete Comparison 2025: React Context vs Zustand vs Jotai vs Redux Toolkit vs Recoil

1. The 2025 State Management Landscape

Competition among state management libraries in the React ecosystem has never been fiercer. Here is the weekly download breakdown for 2025:

LibraryWeekly Downloads (2025)Bundle SizeFirst Release
Redux (+ Toolkit)~9M11KB (RTK)2015
Zustand~5.5M1.1KB2019
TanStack Query~4.5M13KB2020
Jotai~2.2M3.4KB2020
Recoil~500K21KB2020

The Paradigm Shift

The most important change in 2025 is the separation of server state and client state. In the past, Redux handled everything. Now, roles are clearly divided:

  • Server State: TanStack Query / SWR (API data, caching, synchronization)
  • Client State: Zustand / Jotai / Redux Toolkit (UI state, form state, theme)
  • URL State: nuqs / next-usequerystate (search filters, pagination)
  • Form State: React Hook Form / Formik (input values, validation)
┌─────────────────────────────────────────────────┐
Application State├──────────────┬──────────────┬───────────────────┤
Server StateClient StateURL / Form StateTanStack QZustand    │  nuqs + RHFSWRJotai      │                   │
│              │   Redux TK   │                   │
└──────────────┴──────────────┴───────────────────┘

2. The Server State Revolution: TanStack Query and SWR

Why Separate Server State?

In the traditional Redux pattern, API call results were stored in the global store. This approach comes with serious problems:

  • Cache invalidation: You must manually manage when to re-fetch data
  • Loading/Error states: You must manually handle isLoading and error states every time
  • Duplicate requests: Multiple components requesting the same data trigger duplicate API calls
  • Optimistic updates: Implementation is extremely complex

TanStack Query (React Query v5)

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

// Server state fetching - automatic caching and revalidation
function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(res => res.json()),
    staleTime: 5 * 60 * 1000, // Stay fresh for 5 minutes
    gcTime: 30 * 60 * 1000,   // Garbage collect after 30 minutes
  })
}

// Server state mutation - with optimistic update
function useCreateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (newUser: User) =>
      fetch('/api/users', {
        method: 'POST',
        body: JSON.stringify(newUser),
      }).then(res => res.json()),

    // Optimistic update
    onMutate: async (newUser) => {
      await queryClient.cancelQueries({ queryKey: ['users'] })
      const previous = queryClient.getQueryData(['users'])
      queryClient.setQueryData(['users'], (old: User[]) => [...old, newUser])
      return { previous }
    },
    onError: (err, newUser, context) => {
      queryClient.setQueryData(['users'], context?.previous)
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] })
    },
  })
}

The Stale-While-Revalidate Pattern

User request → Check cache → Return cached data immediately (stale)
                 Fetch new data in background (revalidate)
                 Update UI with fresh data

Thanks to this pattern, users always get an instant response while automatically receiving the latest data.

SWR vs TanStack Query

FeatureTanStack Query v5SWR v2
Bundle size13KB4.2KB
Mutation supportBuilt-in useMutationManual implementation
Optimistic updatesFirst-class supportManual implementation
DevtoolsDedicated DevToolsNone
Infinite scrollinguseInfiniteQueryuseSWRInfinite
SSR supportHydration APINext.js integration

3. React Context: When Is It Enough?

When Context Is Appropriate

React Context is a built-in state sharing mechanism that requires no additional library.

// Suitable for infrequently changing state: theme, auth, locale
const ThemeContext = createContext<ThemeContextType | null>(null)

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')

  const value = useMemo(() => ({ theme, setTheme }), [theme])

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  )
}

function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) throw new Error('useTheme must be used within ThemeProvider')
  return context
}

The Performance Trap of Context

The biggest problem with Context is that when the value changes, every consumer re-renders.

// Bad pattern: All state in a single Context
const AppContext = createContext({
  user: null,
  theme: 'light',
  notifications: [],
  sidebar: false,
})

// Problem: Changing just sidebar causes ALL components
// using user, theme, or notifications to re-render!

Solving It with the Composition Pattern

// Good pattern: Separate Context per state concern
function App() {
  return (
    <ThemeProvider>
      <AuthProvider>
        <NotificationProvider>
          <SidebarProvider>
            <Layout />
          </SidebarProvider>
        </NotificationProvider>
      </AuthProvider>
    </ThemeProvider>
  )
}

// Each Context manages only its own state,
// preventing unnecessary re-renders

When Context Is Not Enough

A dedicated state management library is needed when:

  • State changes frequently (counters, timers, real-time data)
  • You need fine-grained subscription to specific state slices
  • Middleware is required (logging, persistence, dev tools)
  • State change logic is complex

4. Redux Toolkit: Is It Still Necessary?

RTK's Current Position

Redux Toolkit (RTK) solved Redux's boilerplate problem and became the official recommendation. In 2025, it remains a strong choice for large-scale enterprise applications.

createSlice: Eliminating Boilerplate

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface TodoState {
  items: Todo[]
  filter: 'all' | 'active' | 'completed'
}

const todoSlice = createSlice({
  name: 'todos',
  initialState: { items: [], filter: 'all' } as TodoState,
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      // Immer is built in - no need to worry about immutability
      state.items.push({
        id: crypto.randomUUID(),
        text: action.payload,
        completed: false,
      })
    },
    toggleTodo: (state, action: PayloadAction<string>) => {
      const todo = state.items.find(t => t.id === action.payload)
      if (todo) todo.completed = !todo.completed
    },
    setFilter: (state, action: PayloadAction<TodoState['filter']>) => {
      state.filter = action.payload
    },
  },
})

export const { addTodo, toggleTodo, setFilter } = todoSlice.actions
export default todoSlice.reducer

RTK Query: Built-in Server State Management

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['User', 'Post'],
  endpoints: (builder) => ({
    getUsers: builder.query<User[], void>({
      query: () => '/users',
      providesTags: ['User'],
    }),
    createUser: builder.mutation<User, Partial<User>>({
      query: (body) => ({
        url: '/users',
        method: 'POST',
        body,
      }),
      invalidatesTags: ['User'],
    }),
  }),
})

export const { useGetUsersQuery, useCreateUserMutation } = api

When Redux Toolkit Is Still Necessary

  • Large teams: Teams that need strict patterns and conventions
  • Complex business logic: Complex interactions among multiple states
  • Time-travel debugging: Powerful debugging with Redux DevTools
  • Middleware ecosystem: Rich middleware support including saga, thunk
  • Legacy compatibility: Gradual migration from existing Redux codebases

5. Zustand: The Triumph of Minimalism

Why Zustand?

Zustand is the lightest state management library at a remarkable 1.1KB bundle size. It requires no Provider, has minimal boilerplate, and works perfectly with TypeScript.

Basic Store Pattern

import { create } from 'zustand'

interface BearStore {
  bears: number
  increase: () => void
  decrease: () => void
  reset: () => void
}

const useBearStore = create<BearStore>((set) => ({
  bears: 0,
  increase: () => set((state) => ({ bears: state.bears + 1 })),
  decrease: () => set((state) => ({ bears: state.bears - 1 })),
  reset: () => set({ bears: 0 }),
}))

// Usage: Call anywhere without a Provider
function BearCounter() {
  const bears = useBearStore((state) => state.bears)
  return <span>{bears} bears</span>
}

function BearControls() {
  const increase = useBearStore((state) => state.increase)
  return <button onClick={increase}>Add bear</button>
}

Middleware: persist, devtools, immer

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

interface AppStore {
  user: User | null
  preferences: Preferences
  setUser: (user: User | null) => void
  updatePreference: (key: string, value: unknown) => void
}

const useAppStore = create<AppStore>()(
  devtools(
    persist(
      immer((set) => ({
        user: null,
        preferences: { theme: 'light', language: 'en' },

        setUser: (user) => set((state) => {
          state.user = user
        }),

        updatePreference: (key, value) => set((state) => {
          state.preferences[key] = value
        }),
      })),
      {
        name: 'app-storage', // localStorage key
        partialize: (state) => ({
          preferences: state.preferences,
        }),
      }
    ),
    { name: 'AppStore' } // DevTools label
  )
)

Slice Pattern: Splitting Large Stores

// userSlice.ts
const createUserSlice = (set: SetState, get: GetState) => ({
  user: null,
  login: async (credentials: Credentials) => {
    const user = await authApi.login(credentials)
    set({ user })
  },
  logout: () => set({ user: null }),
})

// cartSlice.ts
const createCartSlice = (set: SetState, get: GetState) => ({
  items: [],
  addItem: (item: CartItem) =>
    set((state) => ({ items: [...state.items, item] })),
  totalPrice: () =>
    get().items.reduce((sum, item) => sum + item.price, 0),
})

// Combine
const useStore = create((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}))

Zustand's Strengths

  • 1.1KB bundle size (gzipped)
  • No Provider needed: Use anywhere in the component tree
  • Selective subscription: Subscribe to only what you need with (state) => state.bears
  • Works outside React: Also works in vanilla JS
  • SSR compatible: Perfect compatibility with Next.js

6. Jotai: The Power of Atomic State

Atom-based Design

Jotai manages state in "atom" units. Each atom is independent, and only the components that subscribe to it re-render.

import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'

// Basic atoms
const countAtom = atom(0)
const nameAtom = atom('Guest')

// Derived atom
const doubleCountAtom = atom((get) => get(countAtom) * 2)

// Read/Write atom
const countWithLogAtom = atom(
  (get) => get(countAtom),
  (get, set, newValue: number) => {
    console.log(`Count changed: ${get(countAtom)} -> ${newValue}`)
    set(countAtom, newValue)
  }
)

// Usage
function Counter() {
  const [count, setCount] = useAtom(countAtom)
  const doubleCount = useAtomValue(doubleCountAtom)

  return (
    <div>
      <p>Count: {count}, Double: {doubleCount}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  )
}

Async Atoms and Suspense Integration

// Async atom - automatic React Suspense integration
const userAtom = atom(async () => {
  const response = await fetch('/api/user')
  return response.json()
})

// Async atom with dependencies
const userPostsAtom = atom(async (get) => {
  const user = await get(userAtom)
  const response = await fetch(`/api/users/${user.id}/posts`)
  return response.json()
})

// Loading handled via Suspense
function UserPosts() {
  const posts = useAtomValue(userPostsAtom)
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserPosts />
    </Suspense>
  )
}

atomFamily: Dynamic Atom Creation

import { atomFamily } from 'jotai/utils'

// Create independent atoms per ID
const todoAtomFamily = atomFamily((id: string) =>
  atom({
    id,
    text: '',
    completed: false,
  })
)

// Each Todo is managed by an independent atom,
// so changing one Todo does not cause others to re-render
function TodoItem({ id }: { id: string }) {
  const [todo, setTodo] = useAtom(todoAtomFamily(id))
  return (
    <label>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => setTodo(prev => ({ ...prev, completed: !prev.completed }))}
      />
      {todo.text}
    </label>
  )
}

Jotai's Strengths

  • 3.4KB bundle size (gzipped)
  • Atomic re-rendering: Only components subscribed to the changed atom re-render
  • React Suspense integration: Handle async state declaratively
  • Bottom-up approach: Compose state from small units
  • DevTools support: Jotai DevTools, React DevTools integration

7. Recoil: Meta's Choice, but in Decline

Recoil's Current Status

Recoil is an atomic state management library created by Meta (Facebook). However, as of 2025, maintenance has been sluggish and release intervals have widened.

Basic Usage

import { atom, selector, useRecoilState, useRecoilValue } from 'recoil'

const todoListAtom = atom({
  key: 'todoList',
  default: [] as Todo[],
})

const filteredTodoSelector = selector({
  key: 'filteredTodos',
  get: ({ get }) => {
    const list = get(todoListAtom)
    const filter = get(todoFilterAtom)
    switch (filter) {
      case 'completed': return list.filter(t => t.completed)
      case 'active': return list.filter(t => !t.completed)
      default: return list
    }
  },
})

// atomFamily - dynamic atoms
const userAtomFamily = atomFamily({
  key: 'user',
  default: (userId: string) => async () => {
    const res = await fetch(`/api/users/${userId}`)
    return res.json()
  },
})

Recoil vs Jotai

CriteriaRecoilJotai
Bundle size21KB3.4KB
Key requiredMandatory (string key)Not required
ProviderRecoilRoot requiredOptional
MaintenanceSluggishActive
SSRLimitedFull support
CommunityDecliningGrowing

Since Jotai provides all of Recoil's features in a smaller bundle with active maintenance, Jotai is recommended for new projects.


8. Signals: A New Paradigm

What Are Signals?

Signals are state primitives that provide fine-grained reactivity. Unlike React's re-rendering model, when a Signal changes, only the DOM parts that use that Signal are updated directly.

Preact Signals

import { signal, computed, effect } from '@preact/signals-react'

// Create signals
const count = signal(0)
const doubled = computed(() => count.value * 2)

// Use in components - direct DOM update without re-rendering
function Counter() {
  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubled}</p>
      <button onClick={() => count.value++}>+1</button>
    </div>
  )
}

// Side effects
effect(() => {
  console.log(`Count is now: ${count.value}`)
})

SolidJS Signals

import { createSignal, createMemo, createEffect } from 'solid-js'

function Counter() {
  const [count, setCount] = createSignal(0)
  const doubled = createMemo(() => count() * 2)

  createEffect(() => {
    console.log(`Count: ${count()}`)
  })

  return (
    <div>
      <p>Count: {count()}, Doubled: {doubled()}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  )
}

Angular Signals (v17+)

import { signal, computed, effect } from '@angular/core'

@Component({
  template: `
    <p>Count: {{ count() }}, Doubled: {{ doubled() }}</p>
    <button (click)="increment()">+1</button>
  `
})
class CounterComponent {
  count = signal(0)
  doubled = computed(() => this.count() * 2)

  constructor() {
    effect(() => {
      console.log(`Count: ${this.count()}`)
    })
  }

  increment() {
    this.count.update(c => c + 1)
  }
}

React and Signals

The React team has not officially adopted Signals. Instead, React 19's compiler (React Forget) automatically performs similar optimizations. In other words, React chose compiler-based optimization over Signals.

ApproachFrameworksPrinciple
SignalsPreact, Solid, AngularFine-grained reactivity tracking at runtime
CompilerReact 19Automatic memoization at build time
ProxyMobX, ValtioChange detection via Proxy objects

9. Comprehensive Comparison Table

Feature Comparison

CriteriaContextRedux TKZustandJotaiRecoil
Bundle size0KB (built-in)11KB1.1KB3.4KB21KB
BoilerplateMediumLow (RTK)Very lowVery lowMedium
Learning curveLowMediumLowLowMedium
TypeScriptGoodExcellentExcellentExcellentGood
SSR supportGoodGoodExcellentExcellentLimited
DevToolsReact DTRedux DTRedux DTJotai DTLimited
MiddlewareNoneRichpersist/devtoolsUtilitiesNone
Selective subscriptionNouseSelectorselector fnPer atomselector
Provider requiredYesYesNoOptionalYes
Async supportuseEffectcreateAsyncThunkManualSuspenseSuspense

Performance Benchmark (1,000-item List)

ScenarioContextRedux TKZustandJotai
Single item update12.3ms2.1ms1.8ms0.9ms
Full list update15.2ms8.7ms7.9ms8.2ms
Memory usageBaseline+2.1MB+0.3MB+0.5MB
Initial render45ms52ms44ms46ms

10. Project Selection Guide

Decision Framework

What is the project scale?
├── Small (1-3 people, simple UI)
│   ├── Does state change frequently?
│   │   ├── YesZustand
│   │   └── NoReact Context
│   └── Is server data the focus?
│       └── YesTanStack Query alone is sufficient
├── Medium (4-10 people, complex UI)
│   ├── Are there many independent states?
│   │   ├── YesJotai
│   │   └── NoZustand
│   └── Is server state complex?
│       └── YesTanStack Query + Zustand/Jotai
└── Large (10+ people, enterprise)
    ├── Is there existing Redux code?
    │   ├── YesMigrate to Redux Toolkit
    │   └── NoZustand + TanStack Query
    └── Is strict architecture required?
        └── YesRedux Toolkit
ScenarioRecommended Combination
Simple landing pageReact Context
SaaS dashboardZustand + TanStack Query
E-commerceJotai + TanStack Query
Social media appZustand + TanStack Query
Large enterpriseRedux Toolkit + RTK Query
Real-time collaboration toolZustand + WebSocket middleware
Editor/IDE-type toolJotai (fine-grained state control)

11. Migration Guide

From Redux to Zustand

// Before: Redux Toolkit
// store.ts
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1 },
    decrement: (state) => { state.value -= 1 },
  },
})

// Component
function Counter() {
  const count = useSelector((state: RootState) => state.counter.value)
  const dispatch = useDispatch()
  return <button onClick={() => dispatch(increment())}>Count: {count}</button>
}

// After: Zustand
const useCounterStore = create<CounterStore>((set) => ({
  value: 0,
  increment: () => set((s) => ({ value: s.value + 1 })),
  decrement: () => set((s) => ({ value: s.value - 1 })),
}))

function Counter() {
  const count = useCounterStore((s) => s.value)
  const increment = useCounterStore((s) => s.increment)
  return <button onClick={increment}>Count: {count}</button>
}

From Context to Jotai

// Before: React Context
const CountContext = createContext({ count: 0, setCount: () => {} })

function CountProvider({ children }) {
  const [count, setCount] = useState(0)
  return (
    <CountContext.Provider value={{ count, setCount }}>
      {children}
    </CountContext.Provider>
  )
}

function Counter() {
  const { count, setCount } = useContext(CountContext)
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}

// After: Jotai
const countAtom = atom(0)

function Counter() {
  const [count, setCount] = useAtom(countAtom)
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}
// Provider removed! 50% less code!

Migration Checklist

  • Classify existing state into server vs client
  • Move server state to TanStack Query
  • Move client state to your chosen library
  • Update test code
  • Compare performance benchmarks
  • Gradual rollout (migrate feature by feature)

12. Interview Questions and Quiz

Top 10 Interview Questions

Q1. Explain the difference between server state and client state, and recommend appropriate tools for each.

Server state is asynchronous data fetched from APIs where caching, invalidation, and synchronization are key. TanStack Query or SWR is appropriate. Client state is purely frontend state such as UI toggles and form inputs where Zustand, Jotai, or Context is appropriate.

Q2. What is the performance problem with React Context, and how do you solve it?

When Context's value changes, every component subscribing to that Context re-renders. Solutions include splitting Context, memoizing values with useMemo, or switching to Zustand/Jotai.

Q3. What is the core difference between Zustand and Jotai?

Zustand uses a store-based (top-down) approach that groups related state into a single store. Jotai uses an atom-based (bottom-up) approach that splits state into minimal units. Zustand is better when a single store feels natural; Jotai is better when there are many independent states.

Q4. Explain why Redux Toolkit is still relevant in 2025.

Its strengths include strict pattern enforcement, time-travel debugging, a rich middleware ecosystem, built-in RTK Query, and code consistency in large teams.

Q5. Explain the Stale-While-Revalidate pattern.

Cached (stale) data is returned immediately for a fast UX, while new data is fetched in the background (revalidate) and the UI is updated. This is the core strategy of TanStack Query and SWR.

Q6. How do Signals differ from React's re-rendering model?

React re-executes the entire component function on state change and diffs the Virtual DOM. Signals directly update only the DOM parts that use the changed value, avoiding unnecessary component re-execution.

Q7. What problem does Zustand's persist middleware cause in SSR, and how do you fix it?

localStorage does not exist on the server, causing hydration mismatch. Fix this with the skipHydration option or manual hydration inside useEffect.

Q8. Explain the difference between Jotai's derived atom and Recoil's selector.

They are functionally similar, but Jotai requires no string key and has a bundle size 6x smaller. Jotai atoms are identified by reference equality, eliminating key collision risk.

Q9. What is the queryKey design strategy for TanStack Query?

Use hierarchical key structures (e.g., ['users', userId, 'posts']) to enable fine-grained cache invalidation. Invalidating a parent key automatically invalidates child keys.

Q10. What criteria should you use to choose a state management tool for a new project?

Consider project scale, team experience, SSR requirements, bundle size constraints, and state complexity. Most projects should start with the TanStack Query + Zustand combination.

Quiz (5 Questions)

Q1. Why does Zustand not require a Provider?

Zustand creates stores at module scope as global singletons. Because state is managed outside the React component tree, there is no need to wrap components with a Provider. This simplifies setup and eliminates Provider nesting issues.

Q2. What is the difference between TanStack Query's staleTime and gcTime?

staleTime is how long data remains in a "fresh" state. Fresh data is not re-fetched. gcTime (garbage collection time) is how long inactive query data stays in memory before being removed. Defaults are 0 for staleTime and 5 minutes for gcTime.

Q3. Name two advantages of Jotai's atom over Recoil's atom.

First, no string key is required. Jotai atoms are identified by JavaScript object reference, eliminating key collision risk. Second, the bundle size is 3.4KB, roughly one-sixth of Recoil's 21KB.

Q4. Can useMemo alone fully solve React Context's re-rendering problem?

No. useMemo prevents unnecessary re-creation of the value object, but when actual values inside the object change, all consumers still re-render. You need to split Context or use a library that supports selective subscriptions (Zustand, Jotai).

Q5. What is the most important consideration when migrating from Redux Toolkit to Zustand?

Zustand does not have the same middleware chain concept as Redux, so you must redesign sagas and complex async flows. Additionally, Redux DevTools' time-travel debugging is only partially available in Zustand. A strategy of temporarily running both libraries in parallel is needed for gradual migration.


References

  1. Zustand GitHub Repository
  2. Jotai Documentation
  3. Redux Toolkit Official Docs
  4. TanStack Query Documentation
  5. Recoil Documentation
  6. Preact Signals
  7. SolidJS Reactivity
  8. Angular Signals RFC
  9. SWR Documentation
  10. React Documentation - Context
  11. npm trends: State Management Libraries
  12. Theo Browne - State Management in 2025
  13. Jack Herrington - Zustand vs Jotai
  14. TkDodo - Practical React Query