React Query는 서버 데이터의 캐싱, 동기화, 갱신을 자동으로 처리해주는 라이브러리입니다.

"서버에서 받아온 데이터"를 Redux나 Zustand에 넣어서 관리하고 있다면, React Query가 그 역할을 훨씬 잘 해줍니다. 로딩/에러 상태, 캐싱, 재시도, 백그라운드 갱신까지 선언적으로 처리됩니다.


설치와 설정

BASH
npm install @tanstack/react-query
TSX
// App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
      staleTime: 1000 * 60 * 5,  // 5분간 fresh
      gcTime: 1000 * 60 * 30,    // 30분간 캐시 유지
    },
  },
});

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <NavigationContainer>
        <RootNavigator />
      </NavigationContainer>
    </QueryClientProvider>
  );
}

useQuery — 데이터 조회

TSX
import { useQuery } from '@tanstack/react-query';
import { FlatList, Text, View, ActivityIndicator } from 'react-native';

interface Post {
  id: number;
  title: string;
  body: string;
}

// API 함수 분리
async function fetchPosts(): Promise<Post[]> {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  if (!response.ok) throw new Error('데이터를 불러올 수 없습니다');
  return response.json();
}

function PostListScreen() {
  const { data, isLoading, isError, error, refetch, isRefetching } = useQuery({
    queryKey: ['posts'],        // 캐시 키
    queryFn: fetchPosts,        // 데이터 가져오는 함수
  });

  if (isLoading) return <ActivityIndicator style={{ flex: 1 }} />;
  if (isError) return <Text>에러: {error.message}</Text>;

  return (
    <FlatList
      data={data}
      keyExtractor={(item) => String(item.id)}
      renderItem={({ item }) => (
        <View style={{ padding: 16 }}>
          <Text style={{ fontWeight: 'bold' }}>{item.title}</Text>
          <Text numberOfLines={2}>{item.body}</Text>
        </View>
      )}
      // 당겨서 새로고침
      refreshing={isRefetching}
      onRefresh={refetch}
    />
  );
}

파라미터가 있는 쿼리

TSX
async function fetchPost(id: number): Promise<Post> {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
  return res.json();
}

function PostDetailScreen({ route }: { route: { params: { id: number } } }) {
  const { id } = route.params;

  const { data: post, isLoading } = useQuery({
    queryKey: ['post', id],     // id별로 캐싱
    queryFn: () => fetchPost(id),
  });

  if (isLoading) return <ActivityIndicator />;

  return (
    <View style={{ padding: 20 }}>
      <Text style={{ fontSize: 20, fontWeight: 'bold' }}>{post?.title}</Text>
      <Text>{post?.body}</Text>
    </View>
  );
}

useMutation — 데이터 변경

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

interface CreatePostData {
  title: string;
  body: string;
}

async function createPost(data: CreatePostData): Promise<Post> {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  return res.json();
}

function CreatePostScreen({ navigation }) {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      // 게시글 목록 캐시 무효화 → 자동 재조회
      queryClient.invalidateQueries({ queryKey: ['posts'] });
      Alert.alert('성공', '게시글이 작성되었습니다');
      navigation.goBack();
    },
    onError: (error) => {
      Alert.alert('오류', error.message);
    },
  });

  return (
    <View style={{ padding: 20 }}>
      <TextInput
        value={title}
        onChangeText={setTitle}
        placeholder="제목"
      />
      <TextInput
        value={body}
        onChangeText={setBody}
        placeholder="내용"
        multiline
      />
      <Pressable
        onPress={() => mutation.mutate({ title, body })}
        disabled={mutation.isPending}
      >
        <Text>
          {mutation.isPending ? '작성 중...' : '게시글 작성'}
        </Text>
      </Pressable>
    </View>
  );
}

Optimistic Update (낙관적 업데이트)

TSX
const likeMutation = useMutation({
  mutationFn: (postId: number) =>
    fetch(`/api/posts/${postId}/like`, { method: 'POST' }),

  // 서버 응답 전에 UI 먼저 업데이트
  onMutate: async (postId) => {
    // 진행 중인 쿼리 취소
    await queryClient.cancelQueries({ queryKey: ['posts'] });

    // 이전 데이터 백업
    const previousPosts = queryClient.getQueryData(['posts']);

    // 캐시 직접 수정 (UI 즉시 반영)
    queryClient.setQueryData<Post[]>(['posts'], (old) =>
      old?.map((post) =>
        post.id === postId
          ? { ...post, likes: post.likes + 1, isLiked: true }
          : post
      )
    );

    return { previousPosts };
  },

  // 에러 시 롤백
  onError: (_err, _postId, context) => {
    queryClient.setQueryData(['posts'], context?.previousPosts);
  },

  // 성공/실패 관계없이 최신 데이터 가져오기
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});

무한 스크롤

TSX
import { useInfiniteQuery } from '@tanstack/react-query';

interface PageResponse {
  data: Post[];
  nextPage: number | null;
}

async function fetchPostsPage({ pageParam = 1 }): Promise<PageResponse> {
  const res = await fetch(`/api/posts?page=${pageParam}&limit=20`);
  return res.json();
}

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: fetchPostsPage,
    initialPageParam: 1,
    getNextPageParam: (lastPage) => lastPage.nextPage,
  });

  // 모든 페이지의 데이터를 하나의 배열로 합치기
  const allPosts = data?.pages.flatMap((page) => page.data) ?? [];

  return (
    <FlatList
      data={allPosts}
      keyExtractor={(item) => String(item.id)}
      renderItem={({ item }) => <PostCard post={item} />}
      onEndReached={() => {
        if (hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      }}
      onEndReachedThreshold={0.5}
      ListFooterComponent={
        isFetchingNextPage ? <ActivityIndicator /> : null
      }
    />
  );
}

React Native 전용 설정

TSX
import { focusManager, onlineManager } from '@tanstack/react-query';
import { AppState, Platform } from 'react-native';
import NetInfo from '@react-native-community/netinfo';

// 앱이 포그라운드로 돌아올 때 자동 refetch
focusManager.setEventListener((handleFocus) => {
  const subscription = AppState.addEventListener('change', (state) => {
    handleFocus(state === 'active');
  });
  return () => subscription.remove();
});

// 네트워크 상태 감지 → 오프라인에서 온라인 복귀 시 refetch
onlineManager.setEventListener((setOnline) => {
  return NetInfo.addEventListener((state) => {
    setOnline(!!state.isConnected);
  });
});

서버 상태 vs 클라이언트 상태

서버 상태 (React Query)클라이언트 상태 (Zustand/Redux)
API 응답 데이터UI 상태 (모달 열림, 탭 선택)
다른 사용자도 변경 가능현재 사용자만 제어
캐싱과 동기화 필요로컬에서만 관리
게시글, 사용자 목록, 알림테마, 폼 입력값, 사이드바

정리

  • React Query는 서버 데이터의 캐싱, 동기화, 에러/로딩 처리 를 자동화합니다
  • queryKey로 캐시를 식별하고, invalidateQueries로 갱신합니다
  • 낙관적 업데이트 로 UX를 크게 개선할 수 있습니다
  • useInfiniteQuery로 무한 스크롤을 간단히 구현할 수 있습니다
  • 서버 상태는 React Query, 클라이언트 상태는 Zustand — 이렇게 역할을 나누면 깔끔합니다
댓글 로딩 중...