Client State 와 Server StateKey Point는 데이터의 Ownership이 있는 곳이다.
Ownership이 Client에 있다.
Ownership이 Server에 있다.
react-query는 리액트 애플리케이션에서 서버 상태 가져오기, 캐싱, 동기화 및 업데이트를 보다 쉽게 다룰 수 있도록 도와주며 클라이언트 상태와 서버 상태를 명확히 구분하기 위해서 만들어진 라이브러리이다.
react-query에서 기존 상태 관리 라이브러리(redux, mobX)는 클라이언트 상태 작업에 적합하지만 비동기 또는 서버 상태 작업에는 그다지 좋지 않다고 말하고 있다.
클라이언트 상태(Client State)와 서버 상태(Server State)는 완전히 다르며 클라이언트 상태는 컴포넌트에서 관리하는 각각의 input 값으로 예를 들 수 있고 서버 상태는 database에 저장되어있는 데이터로 예를 들 수 있다.
React Query는 우리에게 친숙한 Hook을 사용하여 React Component 내부에서 자연스럽게 서버(또는 비동기적인 요청이 필요한 Source)의 데이터를 사용할 수 있는 방법을 제안한다.
State | Description |
---|---|
✅ fresh | 새롭게 추가된 쿼리 & 만료되지 않은 쿼리 ➜ 컴포넌트가 마운트, 업데이트되어도 데이터 재요청 ❌ |
✅ fetching | 요청 중인 쿼리 |
✅ stale | 만료된 쿼리 ➜ 컴포넌트가 마운트, 업데이트되면 데이터 재요청 ⭕️ |
✅ inactive | 비활성화된 쿼리 ➜ 특정 시간이 지나면 가비지 컬렉터에 의해 제거 |
npm i @tanstack/react-query
캐시를 관리하기 위해 QueryClient 인스턴스를 생성한 후 QueryClientProvider를 통해 컴포넌트가 QueryClient 인스턴스에 접근할 수 있도록 App컴포넌트 최상단에 추가한다.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Provider store={store}>
<App />
</Provider>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);
import axios from 'axios';
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from 'react-query';
// React Query는 내부적으로 queryClient를 사용하여
// 각종 상태를 저장하고, 부가 기능을 제공한다.
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Menus />
</QueryClientProvider>
);
}
function Menus() {
const queryClient = useQueryClient();
// "/menu" API에 Get 요청을 보내 서버의 데이터를 가져온다.
const { data } = useQuery('getMenu', () =>
axios.get('/menu').then(({ data }) => data),
);
// "/menu" API에 Post 요청을 보내 서버에 데이터를 저장한다.
const { mutate } = useMutation(
(suggest) => axios.post('/menu', { suggest }),
{
// Post 요청이 성공하면 위 useQuery의 데이터를 초기화한다.
// 데이터가 초기화되면 useQuery는 서버의 데이터를 다시 불러온다.
onSuccess: () => queryClient.invalidateQueries('getMenu'),
},
);
return (
<div>
<h1> Tomorrow's Lunch Candidates! </h1>
<ul>
{data.map((item) => (
<li key={item.id}> {item.title} </li>
))}
</ul>
<button
onClick={() =>
mutate({
id: Date.now(),
title: 'Toowoomba Pasta',
})
}
>
Suggest Tomorrow's Menu
</button>
</div>
);
}
useQuery Hook으로 수행되는 Query 요청은 HTTP METHOD GET 요청과 같이 서버에 저장되어 있는 "상태"를 불러와
CREATE
같은 작업을 할 때 사용한다.
// 가장 기본적인 형태의 React Query useQuery Hook 사용 예시
const { data } = useQuery(
queryKey,
fetchFn,
options,
);
Description | |
---|---|
✅ queryKey | Query 요청에 대한 응답 데이터를 캐싱할 때 사용할 Unique Key (필수) |
✅ fetchFn | Query 요청을 수행하기 위한 Promise를 Return 하는 함수 (필수) |
✅ options | useQuery에서 사용되는 Option 객체 (선택) |
❗️중요❗️ 쿼리 키가 다르면 호출하는 API가 같더라도 캐싱을 별도로 관리한다.
function Users() {
const { isLoading, error, data } = useQuery(
'userInfo', // 'userInfo'를 Key로 사용하여 데이터 캐싱
// 다른 컴포넌트에서 'userInfo'를 QueryKey로 사용한 useQuery Hook이 있다면 캐시된 데이터를 우선 사용한다.
() => axios.get('/users').then(({ data }) => data),
);
// FYI, `data === undefined`를 평가하여 로딩 상태를 처리하는것이 더 좋다.
// React Query는 내부적으로 stale-while-revalidate 캐싱 전략을 사용하고 있기 때문이다.
if (isLoading) return <div> 로딩중... </div>;
if (error) return <div> 에러: {error.message} </div>;
return (
<div>
{' '}
{data?.map(({ id, name }) => (
<span key={id}> {name} </span>
))}{' '}
</div>
);
}
function UserInfo({ userId }) {
const { isLoading, error, data } = useQuery(
// 'userInfo', userId를 Key로 사용하여 데이터 캐싱
['userInfo', userId],
() => axios.get(`/users/${userId}`)
);
if (isLoading) return <div> 로딩중... </div>;
if (error) return <div> 에러: {error.message} </div>;
return <div> {...} </div>;
}
Description | |
---|---|
✅ data | 서버 요청에 대한 데이터 |
✅ isLoading | 캐시가 없는 상태에서의 데이터 요청 중인 상태 (true / false) |
✅ isFetching | 캐시의 유무 상관없이 데이터 요청 중인 상태 (true / false) |
✅ isError | 서버 요청 실패에 대한 상태 (true / false) |
✅ error | 서버 요청 실패 (object) |
✔ 이외에 더 다양한 데이터가 많다.
options | Description |
---|---|
✅ cacheTime | 언마운트된 후 어느 시점까지 메모리에 데이터를 저장하여 캐싱할 것인지를 결정 |
✅ staleTime | 쿼리가 fresh 상태에서 stale 상태로 전환되는 시간 |
✅ refetchOnMount | 컴포넌트 마운트시 새로운 데이터 패칭 |
✅ refetchOnWindowFocus | 브라우저 클릭 시 새로운 데이터 패칭 |
✅ refetchInterval | 지정한 시간 간격만큼 데이터 패칭 |
✅ refetchIntervalInBackground | 브라우저에 포커스가 없어도 refetchInterval에서 지정한 시간 간격만큼 데이터 패칭 |
✅ enabled | 컴포넌트가 마운트 되어도 데이터 패칭 ❌ |
✅ onSuccess | 데이터 패칭 성공 |
✅ onError | 데이터 패칭 실패 |
✅ select | 데이터 패칭 성공 시 원하는 데이터 형식으로 변환 가능 |
언마운트된 후 어느 시점까지 메모리에 데이터를 저장하여 캐싱할 것인지를 결정
// cacheTime을 3초로 설정
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
cacheTime: 3000,
});
쿼리가 fresh 상태에서 stale 상태로 전환되는 시간
// staleTime을 3초로 설정
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
staleTime: 3000,
});
컴포넌트 마운트시 새로운 데이터 패칭
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
refetchOnMount: true, // or false
});
브라우저 클릭 시 새로운 데이터 패칭
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
refetchOnWindowFocus: true, // or false
});
지정한 시간 간격만큼 데이터 패칭
// 2초 간격으로 데이터 패칭
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
refetchInterval: 2000,
});
브라우저에 포커스가 없어도 refetchInterval에서 지정한 시간 간격만큼 데이터 패칭
// 2초 간격으로 데이터 패칭
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
refetchInterval: 2000,
refetchIntervalInBackground: true,
});
컴포넌트가 마운트 되어도 데이터 패칭 ❌
const { data, isLoading, refetch } = useQuery('super-heroes', fetchSuperHeroes, {
enabled: false,
});
return (
<button onClick={ refetch }>Fetch Button</button>
)
데이터 패칭 성공
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
onSuccess: (data) => {
console.log('데이터 요청 성공', data)
}
});
데이터 패칭 실패
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
onError: (error) => {
console.log('데이터 요청 실패', error)
}
});
데이터 패칭 성공 시 원하는 데이터 형식으로 변환 가능
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes);
console.log(data.data)
/*
[
{id: 1, name: 'batman'},
{id: 2, name: 'superman'},
{id: 3, name: 'wonder woman'},
]
*/
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
select: (data) => {
return data.data.map(hero => hero.name)
}
});
console.log(data) // ['batman', 'superman', 'wonder woman']
데이터 패칭이 여러개 실행되어야 한다면 useQuery를 병렬로 선언하면 된다.
import { useQuery } from 'react-query';
import axios from 'axios';
const fetchSuperHeroes = () => {
return axios.get('http://localhost:4000/superheroes');
};
const fetchFriends = () => {
return axios.get('http://localhost:4000/friends');
};
const ParallelQueries = () => {
const heroes = useQuery('super-heroes', fetchSuperHeroes);
const friends = useQuery('freinds', fetchFriends);
return (
<div>
{heroes.data?.data.map(hero => (
<div key={hero.id}>{hero.name}</div>
)}
{friends.data?.data.map(friend => (
<div key={friend.id}>{friend.name}</div>
)}
</div>
);
};
export default ParallelQueries;
하지만 쿼리의 수가 많아지면 많아질수록 변수를 다 기억해야 하는 단점이 생기고 모든 쿼리에 대한 로딩, 성공, 실패 처리를 다 해줘야 하므로 불편함을 겪을 수 있다. 그럴때는 useQueries를 사용하면 된다.
const results = useQueries([
{
queryKey: ["super-hero"],
queryFn: () => fetchSuperHeroes()
},
{
queryKey: ["freinds"],
queryFn: () => fetchFriends()
}
]);
console.log(results) // 아래 이미지 참조
어느 순간이든 코드가 동기적으로 수행되어야 하는 일이 발생한다. 그럴 때는 위에서 봤던 enabled 속성을 이용하면 된다.
useQuery는 enabled 속성의 값이 true일때 실행된다.
const fetchUserByEmail = (email) => {
return axios.get(`http://localhost:4000/users/${email}`);
};
const fetchCoursesByChannelId = (channelId) => {
return axios.get(`http://localhost:4000/channels/${channelId}`);
};
const DependentQueries = ({ email }) => {
const { data: user } = useQuery(['user', email], () => fetchUserByEmail(email));
const channelId = user?.data.channelId;
// 집중❗️ 이중 부정을 통해서 channelId이 true -> useQuery 실행, false -> 실행 X
useQuery(['courses', channelId], () => fetchCoursesByChannelId(channelId), {
enabled: !!channelId,
});
return <div>DependentQueries</div>;
};
export default DependentQueries;
useMutation Hook으로 수행되는 Mutation 요청은 HTTP METHOD POST, PUT, DELETE 요청과 같이 서버에 Side Effect를 발생시켜 서버의 상태를 변경시킬 때 사용한다.
// 가장 기본적인 형태의 React Query useMutation Hook 사용 예시
const { mutate } = useMutation(
mutationFn,
options,
);
Description | |
---|---|
✅ mutationFn | Mutation 요청을 수행하기 위한 Promise를 Return 하는 함수 (필수) |
✅ options | useMutation에서 사용되는 Option 객체 (선택) |
function NotificationSwitch({ value }) {
// mutate 함수를 호출하여 mutationFn 실행
const { mutate, isLoading } = useMutation(
(value) => axios.post(URL, { value }), // mutationFn
);
return (
<Switch
checked={value}
disabled={isLoading}
onChange={(checked) => {
// mutationFn의 파라미터 'value'로 checked 값 전달
mutate(checked);
}}
/>
);
}
import { useMutation } from 'react-query';
const AddSuperHero = () => {
const addSuperHero = (hero) => {
return axios.post('http://localhost:4000/superheroes', hero);
};
const { mutate: addHero, isLoading, isError, error } = useMutation(addSuperHero);
const handleAddHeroClick = () => {
const hero = { 이름, 성별 };
addHero(hero);
};
if (isLoading) {
return <h2>Loading...</h2>;
}
if (isError) {
return <h2>{error.message}</h2>;
}
}
하지만 버튼을 클릭 후 수동적으로 Fetch를 해줘야 화면에 보여진다는 불편함이 있다.
이 문제점을 해결하기 위해서는 쿼리 무효화(Invalidation)
를 시켜줘야 한다.이 전에 캐싱된 쿼리를 직접 무효화 시킨 후 데이터를 새로 패칭하도록 해야 한다.
import { useMutation, useQueryClient } from 'react-query';
const AddSuperHero = () => {
✅ const queryClient = useQueryClient();
const addSuperHero = (hero) => {
return axios.post('http://localhost:4000/superheroes', hero);
};
const { mutate: addHero, isLoading, isError, error } = useMutation(addSuperHero, {
onSuccess: () => {
// 캐시가 있는 모든 쿼리 무효화
✅ queryClient.invalidateQueries();
// queryKey가 'super-heroes'로 시작하는 모든 쿼리 무효화
✅ queryClient.invalidateQueries('super-heroes');
}
});
const handleAddHeroClick = () => {
const hero = { 이름, 성별 };
addHero(hero);
};
if (isLoading) {
return <h2>Loading...</h2>;
}
if (isError) {
return <h2>{error.message}</h2>;
}
}
mutate 함수가 실행되기 전, 성공 여부, 끝과 같이 라이프사이클에 따라 콜백함수를 작성할 수 있다.
useMutation(addSuperHero, {
onMutate: (variables) => {
// mutate 함수가 실행되기 전에 실행
console.log(variables) // addSuperHero에 들어가는 인자
},
onSuccess: (data, variables) => {
// 성공
},
onError: (error, variables) => {
// 에러 발생
},
onSettled: (data, error, variables, context) => {
// 성공 or 실패 상관 없이 실행
},
})
// Todo.jsx
import useTodosMutation from 'quires/useTodosMutation';
import useTodosQuery from 'quires/useTodosQuery';
import { useForm } from 'react-hook-form';
function Todo() {
// 서버에서 저장되어 있는 Todo 정보를 사용하기 위한 Custom Hook
const { data } = useTodosQuery();
// 서버에 새로운 Todo 정보를 저장하기 위한 Custom Hook
const { mutate } = useTodosMutation();
const { register, handleSubmit } = useForm<{
contents: string;
}>();
const onSubmit = handleSubmit((value) => {
// useTodosMutation의 'mutate' 함수를 사용하여 서버로 데이터를 전송한다.
mutate(value.contents);
});
return (
<div>
<header>
<form onSubmit={onSubmit}>
<input
{...register('contents')}
type="text"
placeholder="What needs to be done?"
autoComplete="off"
/>
</form>
</header>
<div>
<ul>
{data?.map(({ id, contents }) => (
<li key={id}> {contents} </li>
))}
</ul>
</div>
</div>
);
}
export default Todo;
// quires/useTodosQuery.js
import axios from 'axios';
import { useQuery } from 'react-query';
import { TodoItem } from 'types/todo';
// useQuery에서 사용할 UniqueKey를 상수로 선언하고 export로 외부에 노출한다.
// 상수로 UniqueKey를 관리할 경우 다른 컴포넌트 (or Custom Hook)에서 쉽게 참조가 가능하다.
export const QUERY_KEY = '/todos';
// useQuery에서 사용할 서버의 상태를 불러오는데 사용할 Promise를 반환하는 함수
const fetcher = () => axios.get<TodoItem[]>('/todos').then(({ data }) => data);
const useTodosQuery = () => {
return useQuery(QUERY_KEY, fetcher);
};
export default useTodosQuery;
// quires/useTodosMutation.js
import axios from 'axios';
import { useMutation, useQueryClient } from 'react-query';
import { QUERY_KEY as todosQueryKey } from './useTodosQuery';
// useMutation에서 사용할 서버에 Side Effect를 발생시키기 위해 사용할 함수
// 이 함수의 파라미터로는 useMutation의 mutate 함수의 파라미터가 전달된다.
const fetcher = (contents: string) => axios.post('/todos', { contents });
const useTodosMutation = () => {
// mutation 성공 후 useTodosQuery로 관리되는 서버 상태를 다시 불러오기 위한
// Cache 초기화를 위해 사용될 queryClient 객체
const queryClient = useQueryClient();
return useMutation(fetcher, {
// mutate 요청이 성공한 후 queryClient.invalidateQueries 함수를 통해
// useTodosQuery에서 불러온 API Response의 Cache를 초기화
onSuccess: () => queryClient.invalidateQueries(todosQueryKey),
});
};
export default useTodosMutation;
참고