React Query — 서버 상태 관리의 새로운 패러다임
React Query는 서버 데이터의 캐싱, 동기화, 갱신을 자동으로 처리해주는 라이브러리입니다.
"서버에서 받아온 데이터"를 Redux나 Zustand에 넣어서 관리하고 있다면, React Query가 그 역할을 훨씬 잘 해줍니다. 로딩/에러 상태, 캐싱, 재시도, 백그라운드 갱신까지 선언적으로 처리됩니다.
설치와 설정
npm install @tanstack/react-query
// 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 — 데이터 조회
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}
/>
);
}
파라미터가 있는 쿼리
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 — 데이터 변경
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 (낙관적 업데이트)
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'] });
},
});
무한 스크롤
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 전용 설정
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 — 이렇게 역할을 나누면 깔끔합니다
댓글 로딩 중...