TanStack Query vs Zustand: 서버 상태와 클라이언트 상태 차이

2026.01.28·수정 2026.09.14·약 36분·작성: 해비·블로그 소개

이 글에서 정리하는 내용

둘 다 상태를 다루는 도구처럼 보여 API 데이터까지 Zustand에 넣거나, 모달 상태까지 Query로 관리하려는 혼란이 생깁니다.

둘 다 상태를 다루지만 출발점이 다릅니다

TanStack Query와 Zustand는 모두 화면의 값을 다룬다는 점에서 비슷해 보입니다. 하지만 TanStack Query가 주로 다루는 값은 서버에서 온 데이터입니다. 상품 목록, 사용자 정보, 게시글 상세처럼 원본이 서버에 있고 다시 가져오거나 동기화해야 하는 값입니다. Zustand는 모달 열림 여부, 선택한 탭, 임시 필터, 장바구니 UI 상태처럼 클라이언트 안에서 결정되는 값을 다루기에 잘 맞습니다.

서버 상태는 캐시와 동기화가 핵심입니다

서버 상태는 내 앱만 바꾼다고 끝나지 않습니다. 다른 사용자가 데이터를 바꿀 수도 있고, 시간이 지나면 다시 가져와야 할 수도 있습니다. 그래서 데이터의 신선도와 refetch 시점을 정해야 합니다. TanStack Query는 이 문제를 위해 만들어진 도구입니다. API 요청 책임을 나누는 기준은 TanStack Query queryFn 사용법, 캐시 시간은 TanStack Query staleTime gcTime 차이와 이어집니다.

const productsQuery = useQuery({ queryKey: ['products', category], queryFn: () => fetchProducts(category)}); const isFilterOpen = useUiStore((state) => state.isFilterOpen);
상태 소유권: 서버 데이터, TanStack Query, 캐시 동기화

클라이언트 상태는 화면 안의 의사결정에 가깝습니다

클라이언트 상태는 서버에 원본이 없거나, 서버와 즉시 동기화할 필요가 없는 값입니다. 로그인 모달이 열렸는지, 사이드바가 접혔는지, 사용자가 선택한 필터 패널이 무엇인지 같은 값입니다. 이런 값까지 Query 캐시에 넣으면 데이터 요청 도구와 UI 제어가 섞입니다. Zustand는 이런 공유 UI 상태를 작고 명확하게 나누는 데 잘 맞습니다. Zustand의 기본 관점은 Zustand란 무엇인가에서 먼저 잡을 수 있습니다.

같이 쓸 때는 소유권을 나눠야 합니다

상품 목록은 TanStack Query가 가져오고, 사용자가 고른 정렬 옵션이나 필터 패널 열림 상태는 Zustand가 들고 있는 식으로 나눌 수 있습니다. 다만 필터 값이 URL 검색 파라미터와 연결되어야 한다면 또 다른 기준이 필요합니다. 핵심은 값의 원본이 어디인지 묻는 것입니다. 서버가 원본이면 Query, 브라우저 안의 UI 판단이면 Zustand, URL로 공유되어야 하면 search params를 고려합니다.

잘못 섞으면 갱신 기준이 흐려집니다

API 응답을 Zustand에 복사해두면 새 데이터가 들어왔을 때 어떤 값이 최신인지 헷갈립니다. 반대로 모달 상태를 Query에 넣으면 요청과 관계없는 리렌더링 기준이 생깁니다. 두 도구를 같이 쓰는 것 자체가 문제는 아닙니다. 역할이 겹치게 쓰는 것이 문제입니다. 서버 데이터, 클라이언트 UI 상태, URL 상태를 나눠 적어보면 어떤 도구가 맡아야 하는지 대부분 정리됩니다.

상태 소유권 해결: 화면 상태, Zustand, 공유 UI

상태의 주인을 먼저 정하면 도구 선택이 쉬워집니다

상태를 나눌 때 가장 먼저 볼 질문은 “이 값의 원본이 어디인가”입니다. 서버에 원본이 있는 값은 시간이 지나면 바뀔 수 있고, 다시 가져와야 하며, 실패나 로딩 상태도 함께 다뤄야 합니다. 이런 값은 TanStack Query가 맡는 것이 자연스럽습니다. 반면 브라우저 안에서만 결정되는 값은 Zustand나 React의 useState가 더 단순합니다.

예를 들어 상품 목록 데이터는 Query가 가져오고 캐싱합니다. 사용자가 필터 패널을 열었는지, 목록 보기 방식을 카드형으로 볼지 리스트형으로 볼지는 클라이언트 상태입니다. 필터 조건 자체가 URL에 들어가야 공유 가능한 페이지가 된다면 search params까지 고려합니다. 이렇게 서버 상태, 클라이언트 상태, URL 상태를 나누면 도구가 겹치지 않습니다.

실무에서는 한 도구로 모든 상태를 처리하려는 순간 문제가 커집니다. API 응답을 Zustand에 복사해두면 최신 데이터 기준이 흐려지고, 모달 열림 여부를 Query로 관리하면 요청과 관계없는 상태까지 캐시처럼 다루게 됩니다. 역할을 좁게 쓰는 것이 오히려 코드가 단순합니다.

같은 상품 화면에서 상태를 나누는 기준

원본과 저장 위치갱신 방법
상품 이름·가격·재고서버 원본 → Query 캐시refetch·invalidation·mutation 응답
선택한 상품 ID현재 화면 → useState, 여러 화면 공유 시 Zustand선택 이벤트
선택 패널 열림UI → useState 또는 공유 UI store열기·닫기 이벤트
공유 가능한 검색 조건URL search params탐색과 뒤로 가기
저장 전 수정 초안폼 상태입력·제출·취소

Zustand도 비동기 action으로 API를 호출할 수 있습니다. 차이는 “가능한가”보다 누가 재시도·캐시·동기화 규칙을 구현하느냐입니다. Query를 도입한 화면에서 같은 응답 전체를 다시 store로 복사하면 두 사본의 갱신 책임이 생깁니다. 단, 편집 폼은 저장 전 초안을 의도적으로 분리할 수 있습니다. 초안을 여는 시점, 저장 후 초기화, 서버 갱신과 충돌할 때의 정책을 함께 정하세요.

아래 실습은 한 화면만 다루므로 useState로 선택 ID와 패널 열림을 관리합니다. 선택한 상품 객체를 복사하지 않고 query.data?.find(...)로 계산하므로 가격이 새로 들어오면 패널도 같은 가격을 봅니다. 여러 페이지에서 공유해야 한다면 이 두 UI 값만 Zustand store로 옮길 수 있습니다. TanStack Query Provider 설치는 다음 글에서 자세히 다룹니다.

실습: 선택 ID만 보관하는 상품 패널

아래 파일은 하나의 완성 프로젝트입니다. 전체 실습 ZIP 내려받기 후 빈 폴더에서 압축을 풀어 실행하세요. HTML, CSS, JavaScript 파일을 분리했습니다. Node.js 22.12 이상이 필요하며 의존성 설치에는 인터넷 연결이 필요합니다.

npm install
npm test
npm run build
npm run dev

확인한 조합은 React 19.3.0, TanStack Query 5.102.8, Vite 8.3.0, Vitest 4.1.11입니다. 요청은 외부 서버 대신 로컬 fixture를 사용하며 실제 저장·인증·HTTP 서버를 제공하지 않습니다. 테스트는 매 렌더마다 새 QueryClient를 만들어 캐시를 분리합니다.

package.json

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

이 실습은 오류를 재현하는 시작본과 설명, 검증된 완성본을 차례로 비교합니다. 노트를 선택하고 패널을 연 뒤 상품 새로고침을 누르세요. 시작본은 3000원이 남고 완성본은 4500원으로 바뀝니다.

수정 전

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Query 실습</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

package.json

{
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "test": "vitest run"
  },
  "dependencies": {
    "@tanstack/react-query": "5.102.8",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7",
    "@testing-library/jest-dom": "7.0.1"
  }
}

src/App.jsx

import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
export async function loadProducts() {
  return [
    { id: 1, title: '노트', price: 3000 },
    { id: 2, title: '펜', price: 1000 },
  ];
}
export default function App({ load = loadProducts }) {
  const [selected, setSelected] = useState(null);
  const [showPanel, setShowPanel] = useState(false);
  const query = useQuery({ queryKey: ['ownership', 'products'], queryFn: load });
  return (
    <main>
      <h1>상품 선택과 데이터 소유권</h1>
      <button onClick={() => setShowPanel((open) => !open)}>
        선택 패널 {showPanel ? '닫기' : '열기'}
      </button>
      {query.isPending && <p role="status">상품 요청 중</p>}
      {query.isError && <p role="alert">{query.error.message}</p>}
      <ul>
        {query.data?.map((product) => (
          <li key={product.id}>
            {product.title} {product.price}원{' '}
            <button onClick={() => setSelected(product)}>{product.title} 선택</button>
          </li>
        ))}
      </ul>
      {showPanel && (
        <section aria-label="선택 상품">
          {selected ? `${selected.title} / ${selected.price}원` : '선택한 상품 없음'}
        </section>
      )}
      <button onClick={() => void query.refetch()} disabled={query.isFetching}>
        상품 새로고침
      </button>
    </main>
  );
}

src/main.jsx

import React from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App.jsx';
import './styles.css';
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
createRoot(document.getElementById('root')).render(
  <QueryClientProvider client={client}>
    <App />
  </QueryClientProvider>,
);

src/styles.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  background: #fff;
  color: #222;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
}
main {
  max-width: 760px;
  margin: 40px auto;
  padding: 24px;
}
button,
input,
select {
  font: inherit;
  padding: 8px 12px;
  border: 1px solid #777;
  background: #fff;
  color: #222;
  margin: 4px;
}
button:disabled {
  color: #777;
}
section {
  border-top: 1px solid #aaa;
  padding-block: 16px;
}
li {
  margin-block: 8px;
}
:focus-visible {
  outline: 2px solid #222;
  outline-offset: 3px;
}

test/App.test.jsx

import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { expect, test, vi } from 'vitest';
import App from '../src/App.jsx';
function show(props = {}) {
  const client = new QueryClient({
    defaultOptions: { queries: { retry: false, gcTime: 0 } },
  });
  const view = render(
    <QueryClientProvider client={client}>
      <App {...props} />
    </QueryClientProvider>,
  );
  return { client, ...view };
}
test('선택 ID와 패널은 UI 상태이며 갱신된 가격은 Query에서 파생한다', async () => {
  const load = vi
    .fn()
    .mockResolvedValueOnce([{ id: 1, title: '노트', price: 3000 }])
    .mockResolvedValueOnce([{ id: 1, title: '노트', price: 4500 }]);
  show({ load });
  const user = userEvent.setup();
  await user.click(await screen.findByRole('button', { name: '노트 선택' }));
  await user.click(screen.getByRole('button', { name: '선택 패널 열기' }));
  expect(screen.getByRole('region')).toHaveTextContent('노트 / 3000원');
  expect(load).toHaveBeenCalledTimes(1);
  await user.click(screen.getByRole('button', { name: '상품 새로고침' }));
  await waitFor(() =>
    expect(screen.getByRole('region')).toHaveTextContent('노트 / 4500원'),
  );
  expect(load).toHaveBeenCalledTimes(2);
});
test('선택한 상품이 서버 목록에서 사라지면 파생 선택도 비운다', async () => {
  const load = vi
    .fn()
    .mockResolvedValueOnce([{ id: 1, title: '노트', price: 3000 }])
    .mockResolvedValueOnce([]);
  show({ load });
  const user = userEvent.setup();
  await user.click(await screen.findByRole('button', { name: '노트 선택' }));
  await user.click(screen.getByRole('button', { name: '선택 패널 열기' }));
  await user.click(screen.getByRole('button', { name: '상품 새로고침' }));
  await waitFor(() =>
    expect(screen.getByRole('region')).toHaveTextContent('선택한 상품 없음'),
  );
});

test/setup.js

import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(cleanup);

vite.config.js

import { defineConfig } from 'vitest/config';
export default defineConfig({
  oxc: { jsx: { runtime: 'automatic' } },
  test: { environment: 'jsdom', setupFiles: './test/setup.js' },
});

수정 방법

index.html

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

package.json

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

src/App.jsx

수정 위치: 서버 데이터의 상품 객체 전체를 setSelected(product)로 복사해 두는 부분을 확인하세요.

이유: 상품 객체를 UI state에 복제하면 refetch 뒤 Query 캐시는 4500원이어도 패널은 복사 당시 3000원을 표시합니다. selectedId만 저장하고 query.data에서 find로 매 렌더마다 상품을 찾으면 최신 가격을 표시하며, 상품이 사라진 경우도 자연스럽게 선택 없음이 됩니다.

재현과 기대 결과: 노트를 선택하고 패널을 연 뒤 상품 새로고침을 누르세요. 시작본은 3000원이 남고 완성본은 4500원으로 바뀝니다.

src/App.jsx에서 아래 차이가 있는 위치를 수정하세요. -는 삭제할 줄, +는 추가할 줄이며 표시는 실제 코드에 넣지 않습니다.

본문의 확인 절차로 변경 결과를 확인합니다.

--- 수정 전/src/App.jsx
+++ 수정 후/src/App.jsx
@@ -7,9 +7,10 @@
   ];
 }
 export default function App({ load = loadProducts }) {
-  const [selected, setSelected] = useState(null);
+  const [selectedId, setSelectedId] = useState(null);
   const [showPanel, setShowPanel] = useState(false);
   const query = useQuery({ queryKey: ['ownership', 'products'], queryFn: load });
+  const selected = query.data?.find((product) => product.id === selectedId);
   return (
     <main>
       <h1>상품 선택과 데이터 소유권</h1>
@@ -22,7 +23,9 @@
         {query.data?.map((product) => (
           <li key={product.id}>
             {product.title} {product.price}원{' '}
-            <button onClick={() => setSelected(product)}>{product.title} 선택</button>
+            <button onClick={() => setSelectedId(product.id)}>
+              {product.title} 선택
+            </button>
           </li>
         ))}
       </ul>

수정 후 탭의 전체 파일과 비교한 뒤 본문의 확인 절차를 실행하세요.

src/main.jsx

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

src/styles.css

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

test/App.test.jsx

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

test/setup.js

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

vite.config.js

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

수정 후

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Query 실습</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

package.json

{
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "test": "vitest run"
  },
  "dependencies": {
    "@tanstack/react-query": "5.102.8",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7",
    "@testing-library/jest-dom": "7.0.1"
  }
}

src/App.jsx

import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
export async function loadProducts() {
  return [
    { id: 1, title: '노트', price: 3000 },
    { id: 2, title: '펜', price: 1000 },
  ];
}
export default function App({ load = loadProducts }) {
  const [selectedId, setSelectedId] = useState(null);
  const [showPanel, setShowPanel] = useState(false);
  const query = useQuery({ queryKey: ['ownership', 'products'], queryFn: load });
  const selected = query.data?.find((product) => product.id === selectedId);
  return (
    <main>
      <h1>상품 선택과 데이터 소유권</h1>
      <button onClick={() => setShowPanel((open) => !open)}>
        선택 패널 {showPanel ? '닫기' : '열기'}
      </button>
      {query.isPending && <p role="status">상품 요청 중</p>}
      {query.isError && <p role="alert">{query.error.message}</p>}
      <ul>
        {query.data?.map((product) => (
          <li key={product.id}>
            {product.title} {product.price}원{' '}
            <button onClick={() => setSelectedId(product.id)}>
              {product.title} 선택
            </button>
          </li>
        ))}
      </ul>
      {showPanel && (
        <section aria-label="선택 상품">
          {selected ? `${selected.title} / ${selected.price}원` : '선택한 상품 없음'}
        </section>
      )}
      <button onClick={() => void query.refetch()} disabled={query.isFetching}>
        상품 새로고침
      </button>
    </main>
  );
}

src/main.jsx

import React from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App.jsx';
import './styles.css';
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
createRoot(document.getElementById('root')).render(
  <QueryClientProvider client={client}>
    <App />
  </QueryClientProvider>,
);

src/styles.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  background: #fff;
  color: #222;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
}
main {
  max-width: 760px;
  margin: 40px auto;
  padding: 24px;
}
button,
input,
select {
  font: inherit;
  padding: 8px 12px;
  border: 1px solid #777;
  background: #fff;
  color: #222;
  margin: 4px;
}
button:disabled {
  color: #777;
}
section {
  border-top: 1px solid #aaa;
  padding-block: 16px;
}
li {
  margin-block: 8px;
}
:focus-visible {
  outline: 2px solid #222;
  outline-offset: 3px;
}

test/App.test.jsx

import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { expect, test, vi } from 'vitest';
import App from '../src/App.jsx';
function show(props = {}) {
  const client = new QueryClient({
    defaultOptions: { queries: { retry: false, gcTime: 0 } },
  });
  const view = render(
    <QueryClientProvider client={client}>
      <App {...props} />
    </QueryClientProvider>,
  );
  return { client, ...view };
}
test('선택 ID와 패널은 UI 상태이며 갱신된 가격은 Query에서 파생한다', async () => {
  const load = vi
    .fn()
    .mockResolvedValueOnce([{ id: 1, title: '노트', price: 3000 }])
    .mockResolvedValueOnce([{ id: 1, title: '노트', price: 4500 }]);
  show({ load });
  const user = userEvent.setup();
  await user.click(await screen.findByRole('button', { name: '노트 선택' }));
  await user.click(screen.getByRole('button', { name: '선택 패널 열기' }));
  expect(screen.getByRole('region')).toHaveTextContent('노트 / 3000원');
  expect(load).toHaveBeenCalledTimes(1);
  await user.click(screen.getByRole('button', { name: '상품 새로고침' }));
  await waitFor(() =>
    expect(screen.getByRole('region')).toHaveTextContent('노트 / 4500원'),
  );
  expect(load).toHaveBeenCalledTimes(2);
});
test('선택한 상품이 서버 목록에서 사라지면 파생 선택도 비운다', async () => {
  const load = vi
    .fn()
    .mockResolvedValueOnce([{ id: 1, title: '노트', price: 3000 }])
    .mockResolvedValueOnce([]);
  show({ load });
  const user = userEvent.setup();
  await user.click(await screen.findByRole('button', { name: '노트 선택' }));
  await user.click(screen.getByRole('button', { name: '선택 패널 열기' }));
  await user.click(screen.getByRole('button', { name: '상품 새로고침' }));
  await waitFor(() =>
    expect(screen.getByRole('region')).toHaveTextContent('선택한 상품 없음'),
  );
});

test/setup.js

import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(cleanup);

vite.config.js

import { defineConfig } from 'vitest/config';
export default defineConfig({
  oxc: { jsx: { runtime: 'automatic' } },
  test: { environment: 'jsdom', setupFiles: './test/setup.js' },
});

package.json 전체 코드 보기

index.html

index.html 전체 코드 보기

vite.config.js

vite.config.js 전체 코드 보기

src/main.jsx

src/main.jsx 전체 코드 보기

src/styles.css

src/styles.css 전체 코드 보기

src/App.jsx

src/App.jsx 전체 코드 보기

test/setup.js

test/setup.js 전체 코드 보기

test/App.test.jsx

test/App.test.jsx 전체 코드 보기

검증 결과와 적용 범위

선택 패널을 열어도 요청 수가 늘지 않는지, 새 응답의 가격이 선택 패널에도 반영되는지, 선택 상품이 삭제되면 선택 결과를 비우는지 확인합니다.

이 프로젝트는 Vitest/jsdom 테스트와 Vite production build를 통과했습니다. jsdom은 실제 브라우저가 아니므로 레이아웃·키보드 포커스의 브라우저별 차이·실제 네트워크·CORS·서버 권한·전송 취소까지 검증한 것은 아닙니다. 테스트의 fixture와 주입 함수는 정해진 입력으로 화면과 Query 동작을 확인하는 용도입니다.

공식 문서

2026-09-12에 확인한 React v5 문서입니다. 실습은 설치된 5.102.8에서 별도로 검증했습니다.

연결 학습: 상태의 범위와 소유권은 다른 축입니다

목표·선행 지식: 지역·전역은 사용 범위이고 서버 상태는 원본 소유권과 동기화 문제라는 점을 구분합니다.

서버 데이터도 여러 컴포넌트가 공유할 수 있습니다. 따라서 지역·전역·서버가 언제나 서로 배타적인 세 상자라고 이해하지 마세요. 입력 초안은 useState, 클라이언트 공통 설정은 Zustand, API 응답과 요청 수명은 Query로 나누는 것이 이 과정의 선택입니다.

직접 확인할 과제

서버 Todo 응답을 Zustand에도 복사하면 수정 시 어느 쪽을 먼저 갱신해야 하는지 적어 보세요. 복사가 꼭 필요한 이유가 없다면 서버 캐시의 원본을 하나로 유지합니다.

공통 실습 ZIP · 다음 학습 · 라이브러리 선택 가이드

공통 ZIP은 버전을 고정한 학습 예제입니다. UI 파일은 Radix·Sonner·Embla 기반 축약 구현이며 shadcn CLI 생성물과 동일하지 않습니다. 적용 범위와 실행 방법은 ZIP의 README를 확인하세요.

이 글이 도움이 되었나요?

조회 중

TanStack Query 학습 순서

필수 11개 · 전체 12개

읽음 기록 관리

전체 과정 목차 (12개)
  1. 필수 길잡이 · TanStack Query vs Zustand: 서버 상태와 클라이언트 상태 차이 현재 글
  2. 필수 학습 · TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드
  3. 필수 학습 · TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기
  4. 필수 학습 · TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기
  5. 필수 학습 · TanStack Query staleTime gcTime 차이: 캐시 시간 기준
  6. 필수 학습 · TanStack Query useMutation 저장 실습: 실패·재시도와 목록 갱신까지
  7. 선택 참고 · TanStack Query 화면 갱신 문제 해결: 데이터 변경 후 UI가 바뀌지 않을 때
  8. 필수 학습 · TanStack Query 페이지네이션 실습: placeholderData로 이전 목록 유지하기
  9. 필수 학습 · TanStack Query 무한 스크롤 사용법: useInfiniteQuery로 목록 이어 불러오기
  10. 필수 선수 · TanStack Query Hydration 오류 해결: QueryClient 설정 기준
  11. 필수 학습 · TanStack Query Todo 실습: 서버 연결·낙관적 업데이트·실패 롤백
  12. 필수 학습 · TanStack Query 수동 캐시 정규화: 목록·상세 동기화와 선택 기준

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기