-
React Query카테고리 없음 2022. 7. 11. 19:33
Introduction
What?
A library for fetching data in a React application
Why?
- Since React is a UI library, there is no specific pattern for data fetching
useEffect
hook for data fetching anduseState
hook to maintain component state like loading, error or resulting data- If the data is needed throughouut the app, we tend to use state management libraries
- Most of the state management libraries are good for working with client state. Ex:
theme
for the application / whether a modal is open - State management libraries are not great for working with asynchronous or server state
Client state vs Server state
Client state
- Persisted in your app memory and accessing or updating it is synchronous
Server state
- Persisted remotely and requires asynchronous APIs for fetching or updating
- Has shared ownership
- Data can be updated by someone else without your knowledge
- UI data may not be in sync with the remote data
- Challenging when you have to deal with caching, deduping multiple requests for the same data, updating stale data in the background, performance optimizations etc
Content
- Basic querires
- Poll data
- React query dev tools
- Create reusable query hooks
- Query by ID
- Parallel queries
- Dynamic queries
- Dependent queries
- Infinite & paginated queries
- Update data using mutations
- Invalidate queries
- Optimistic updates
- Axios Intercetor
Pre-requisites
- React Fundamentals
- React Hooks
Project Setup
- New react project using CRA
- Set up an API endpoint that serves mock data for use in our application
- Set up react router and a few routes in the application
- Fetch data the traditional way using
useEffect
anduseState
Traditional way fetching data
src/components/SuperHeroes.page.tsx
import { useEffect, useState } from 'react'; import axios from 'axios'; import { Hero } from '../typings/Heroes'; const SuperHeroesPage = () => { const [isLoading, setIsLoading] = useState(true); const [data, setData] = useState<Hero[]>([]); useEffect(() => { axios.get('http://localhost:4000/superheroes').then((res) => { setData(res.data); setIsLoading(false); }); }, []); if (isLoading) { return <h2>Loading...</h2>; } return ( <> <h2>Super Heroes Page</h2> {data.map((hero) => { return <div key={hero.name}>{hero.name}</div>; })} </> ); }; export default SuperHeroesPage;
Fetching data with
useQuery
Using
QueryClientProvider
andQueryClient
src/App.tsx
import { BrowserRouter as Router, Link, Route, Switch } from 'react-router-dom'; import { QueryClientProvider, QueryClient } from 'react-query'; import './App.css'; import HomePage from './components/Home.page'; import RQSuperHeroesPage from './components/RQSuperHeroes.page'; import SuperHeroesPage from './components/SuperHeroes.page'; const queryClient = new QueryClient(); const App = () => { return ( <QueryClientProvider client={queryClient}> <Router> <div> <nav> <ul> <li> <Link to="/">Home</Link> </li> <li> <Link to="/super-heroes">Traditional Super Heroes</Link> </li> <li> <Link to="/rq-super-heroes">RQ Super Heroes</Link> </li> </ul> </nav> <Switch> <Route path="/super-heroes"> <SuperHeroesPage /> </Route> <Route path="/rq-super-heroes"> <RQSuperHeroesPage /> </Route> <Route path="/"> <HomePage /> </Route> </Switch> </div> </Router> </QueryClientProvider> ); }; export default App;
Using
useQuery
src/components/RQSuperHeroes.page.tsx
import axios from 'axios'; import { useQuery } from 'react-query'; import { Hero } from '../typings/Heroes'; const fetchSuperHeroes = () => { return axios.get('http://localhost:4000/superheroes'); }; const RQSuperHeroesPage = () => { const { isLoading, data } = useQuery('super-heroes', fetchSuperHeroes); if (isLoading) { return <h2>Loading...</h2>; } return ( <> <h2>RQ Super Heroes Page</h2> {data?.data.map((hero: Hero) => ( <div key={hero.name}>{hero.name}</div> ))} </> ); }; export default RQSuperHeroesPage;
Handing Query Error
Traditional way handling error
src/components/SuperHeroes.page.tsx
import { useEffect, useState } from 'react'; import axios from 'axios'; import { Hero } from '../typings/Heroes'; const SuperHeroesPage = () => { const [isLoading, setIsLoading] = useState(true); const [data, setData] = useState<Hero[]>([]); const [error, setError] = useState(''); useEffect(() => { axios .get('http://localhost:4000/superheroes1') .then((res) => { setData(res.data); setIsLoading(false); }) .catch((error) => { setError(error.message); setIsLoading(false); }); }, []); if (isLoading) { return <h2>Loading...</h2>; } if (error) { return <h2>{error}</h2>; } return ( <> <h2>Super Heroes Page</h2> {data.map((hero) => { return <div key={hero.name}>{hero.name}</div>; })} </> ); }; export default SuperHeroesPage;
src/components/RQSuperHeroes.page.tsx
import axios, { AxiosError, AxiosResponse } from 'axios'; import { useQuery } from 'react-query'; import { Hero } from '../typings/Heroes'; const fetchSuperHeroes = () => { return axios.get('http://localhost:4000/superheroes'); }; const RQSuperHeroesPage = () => { const { isLoading, data, isError, error } = useQuery<AxiosResponse<Hero[]>, AxiosError>( 'super-heroes', fetchSuperHeroes ); if (isLoading) { return <h2>Loading...</h2>; } if (isError) { return <h2>{error.message}</h2>; } return ( <> <h2>RQ Super Heroes Page</h2> {data?.data.map((hero) => ( <div key={hero.name}>{hero.name}</div> ))} </> ); }; export default RQSuperHeroesPage;
React query devtools
src/App.tsx
import { BrowserRouter as Router, Link, Route, Switch } from 'react-router-dom'; import { QueryClientProvider, QueryClient } from 'react-query'; import { ReactQueryDevtools } from 'react-query/devtools'; import './App.css'; import HomePage from './components/Home.page'; import RQSuperHeroesPage from './components/RQSuperHeroes.page'; import SuperHeroesPage from './components/SuperHeroes.page'; const queryClient = new QueryClient(); const App = () => { return ( <QueryClientProvider client={queryClient}> <Router> <div> <nav> <ul> <li> <Link to="/">Home</Link> </li> <li> <Link to="/super-heroes">Traditional Super Heroes</Link> </li> <li> <Link to="/rq-super-heroes">RQ Super Heroes</Link> </li> </ul> </nav> <Switch> <Route path="/super-heroes"> <SuperHeroesPage /> </Route> <Route path="/rq-super-heroes"> <RQSuperHeroesPage /> </Route> <Route path="/"> <HomePage /> </Route> </Switch> </div> </Router> <ReactQueryDevtools initialIsOpen={false} position="bottom-right" /> </QueryClientProvider> ); }; export default App;
Query cache
Query cache that react query provides by default every query result is cached for five minutes and react query relies on that cache for subsequent requests.
How
useQuery
works with respect to cachingconst fetchSuperHeroes = () => { return axios.get('http://localhost:4000/superheroes'); }; const RQSuperHeroesPage = () => { const { isLoading, data, isError, error } = useQuery<AxiosResponse<Hero[]>, AxiosError>( 'super-heroes', fetchSuperHeroes ); // ... }; export default RQSuperHeroesPage;
useQuery
is fired forsuper-heroes
key.isLoading
is set totrue
and a network request is sent to fetch the data.- When the request is completed, it is cached using the query key and the
fetchSuperHeroes
function as the unique idetifiers. - When we navigate to the home page and revisit the
/rq-super-heroes
page, react query will check if the data for this query exists in cache. - Since it does the cached data is immediately returned without
isLoading
set totrue
. That is the reasone we don't see the loading text for subsequent requests.
However react query knows that the server data might have updated and the cache might not contain the latest data. So a background refetch is triggered for the same query and if the fetch is successful the new data is updated in the UI. Since our data is the same as the cached data, we don't see any change in the UI.
isFetching
isLoading
is not changed. DoesuseQuery
provide another boolean flag to indicate the background refetching of the query. The answer is yes and the flag is calledisFetching
.// ... const RQSuperHeroesPage = () => { const { isLoading, data, isError, error, isFetching } = useQuery<AxiosResponse<Hero[]>, AxiosError>( 'super-heroes', fetchSuperHeroes ); console.log(isLoading, isFetching); // ... }; export default RQSuperHeroesPage;
If the data has been changed, we will see the cached list from before and then the list updates when the background refetching has completed.
React query out of the box leads to better user experience as there is a list being displayed already and then the list updates in the background. A user does not have to see the loading indicator every single time.Chache time
React query sets a default value of 5 minutes for the query cache and that is a good default which you can leave as it is. If you really want to change it though pass in a third argument to
useQuery
.// ... const RQSuperHeroesPage = () => { const { isLoading, data, isError, error, isFetching } = useQuery<AxiosResponse<Hero[]>, AxiosError>( 'super-heroes', fetchSuperHeroes, { cacheTime: 5000 } ); // ... }; export default RQSuperHeroesPage;
Stale time
Another use of query cache is to reduce the number of network requests for data that doesn't necessarily change too often. For example, let's say our list of superheroes does not change often and it is okay if the user sees stale data for a while. In such cases, we can use the cached query results without having to refetch in the background. To achieve that behavior we configure another property called
staleTime
. The defualtstaleTime
is 0 seconds.// ... const RQSuperHeroesPage = () => { const { isLoading, data, isError, error, isFetching } = useQuery<AxiosResponse<Hero[]>, AxiosError>( 'super-heroes', fetchSuperHeroes, { staleTime: 30000 } ); // ... }; export default RQSuperHeroesPage;
Refetch Defaults
Let's learn two more configurations related to refetching for which react query provides a default value.
reetchOnMount
If it's set totrue
, the query will refetch on mount if the data is stale. (default)
If it's set tofalse
, the query will not refetch on mount.
If it's set to'always'
, the query will always refetch the data when the component mount.
// ... const RQSuperHeroesPage = () => { const { isLoading, data, isError, error, isFetching } = useQuery<AxiosResponse<Hero[]>, AxiosError>( 'super-heroes', fetchSuperHeroes, { reetchOnMount: true } ); // ... }; export default RQSuperHeroesPage;
refetchOnWindowFocus
If a user leaves your application and returns to stale data, React Query automatically requests fresh data for you in the background. You can disable this globally or per-query using therefetchOnWindowFocus
option.
If set totrue
, the query will refetch on window focus if the data is stale.
If set tofalse
, the query will not refetch on window focus.
If set to'always'
, the query always refetch on window focust.
// ... const RQSuperHeroesPage = () => { const { isLoading, data, isError, error, isFetching } = useQuery<AxiosResponse<Hero[]>, AxiosError>( 'super-heroes', fetchSuperHeroes, { refetchOnWindowFocus: true } ); // ... }; export default RQSuperHeroesPage;
Polling
Polling basically refers to the process of fetching data at regular intervals. For example, if you have a component that shows the real-time price of different stocks, you might want to fetch data every second to update the UI. This ensures the UI will always be in sync with the remote data irrespective of configurations like
refetchOnMount
orrefetchOnWindowFocus
which is dependent on user interaction. Now to pull data with react query, we can make use of another configuration calledrefetchInterval
.refetchInterval
By default, it is set to
false
. If set to a number, all queries will continuously refetch at this frequency in milliseconds.// ... const RQSuperHeroesPage = () => { const { isLoading, data, isError, error, isFetching } = useQuery<AxiosResponse<Hero[]>, AxiosError>( 'super-heroes', fetchSuperHeroes, { refetchInterval: 2000 } ); // ... }; export default RQSuperHeroesPage;
refetchIntervalInBackground
The pulling or automatic refetching is paused if the window loses focus. If you do want background refetching at regular intervals, you can specify another configuration call
refetchIntervalInBackground
and set it totrue
.// ... const RQSuperHeroesPage = () => { const { isLoading, data, isError, error, isFetching } = useQuery<AxiosResponse<Hero[]>, AxiosError>( 'super-heroes', fetchSuperHeroes, { refetchInterval: 2000, refetchIntervalInBackground: true, } ); // ... }; export default RQSuperHeroesPage;
useQuery
on clickWe might have to fetch the data based on a user event and not when the component mounts. There are two steps that we need to implement.
- The first step is to inform
useQuery
not to fire the get request when the component mounts. We do that by passing in a configuration calledenabled
and setting it tofalse
.// ...
const RQSuperHeroesPage = () => {
const { isLoading, data, isError, error, isFetching } = useQuery<AxiosResponse<Hero[]>, AxiosError>(
'super-heroes',
fetchSuperHeroes,
{
enabled: false,
}
);// ...
};export default RQSuperHeroesPage;
2. Step two, we fetch the data on click of a button using `refetch` which is a function returned by `useQuery`. ```tsx // ... const RQSuperHeroesPage = () => { const { isLoading, data, isError, error, isFetching, refetch } = useQuery<AxiosResponse<Hero[]>, AxiosError>( 'super-heroes', fetchSuperHeroes, { enabled: false, } ); // ... return ( <> <h2>RQ Super Heroes Page</h2> <button onClick={() => refetch()}>Fetch heroes</button> {data?.data.map((hero) => ( <div key={hero.name}>{hero.name}</div> ))} </> ); }; export default RQSuperHeroesPage;
Success and Error Callbacks
Sometimes we might want to perform a side effect when the query completes. An example could be opening a modal, navigating to a different route or even displaying toast notifications. To cater to these scenaros react query let us specify auccess and error callbacks as configurations or options to the
useQuery
hook.// ... const RQSuperHeroesPage = () => { const onSuccess = (data: AxiosResponse) => { console.log('Perform side effect after data fetching.', data); }; const onError = (error: AxiosError) => { console.log('Perform side effect after encountering error.', error); }; const { isLoading, data, isError, error, isFetching, refetch } = useQuery<AxiosResponse<Hero[]>, AxiosError>( 'super-heroes', fetchSuperHeroes, { onSuccess, onError, } ); // ... }; export default RQSuperHeroesPage;
Data Transformation
You've run into the scenario of needing to transform data into a format that the front-end component can consume. The back-end folks have their own convention and the front-end might have a different convention. To help with such scenarios react query provides a
select
configuration option which we can specify on theuseQuery
hook// ... const RQSuperHeroesPage = () => { // ... const { isLoading, data, isError, error, isFetching, refetch } = useQuery< AxiosResponse<Hero[]>, AxiosError, string[] >('super-heroes', fetchSuperHeroes, { onSuccess, onError, select: (data) => { return data.data.map((hero) => hero.name); }, }); // ... return ( <> <h2>RQ Super Heroes Page</h2> {data?.map((heroName) => ( <div key={heroName}>{heroName}</div> ))} </> ); }; export default RQSuperHeroesPage;
Custom Query Hook
For larger apps, you might want to reuse the data fetching logic. For example, the same query might be required in a different component. One approach would be to duplicate the code we've written here. However we all know that is not the best way to write code. What we need is a way to reuse the
useQuery
hook. Let's learn how to create a custom hook that wraps around theuseQuery
hook. That will allow us to call the custom hook from multiple components without having to duplicate the code.src/hooks/useSuperHeroesData.ts
import axios, { AxiosError, AxiosResponse } from 'axios'; import { useQuery } from 'react-query'; import { Hero } from '../typings/Heroes'; const fetchSuperHeroes = () => { return axios.get('http://localhost:4000/superheroes'); }; export const useSuperHeroesData = ( onSuccess?: (data: string[]) => void, onError?: (err: AxiosError<unknown, any>) => void ) => { return useQuery<AxiosResponse<Hero[]>, AxiosError, string[]>('super-heroes', fetchSuperHeroes, { onSuccess, onError, select: (data) => { return data.data.map((hero) => hero.name); }, }); };
Query by Id
Querying by Id setup
- Create a new page that will eventually display the details about one single super hero.
- Configure the route to that page and add a link from the super heroes list page to the super hero details page.
- Fetch a superhero by id and display the details in the UI.
src/hooks/useSuperHeroData.ts
import axios from 'axios'; import { useQuery } from 'react-query'; const fetchSuperHero = ({ queryKey }: { queryKey: string[] }) => { const heroId = queryKey[1]; return axios.get(`http://localhost:4000/superheroes/${heroId}`); }; export const useSuperHeroData = (heroId: string) => { return useQuery(['super-hero', heroId], fetchSuperHero); };
Parallel Queries
Sometimes a single component needs to call multiple APIs to fetch the necessary data. With react query, it is as simple as calling
useQuery
twice.import axios from 'axios'; import { useQuery } from 'react-query'; const fetchSuperHeroes = () => { return axios.get('http://localhost:4000/superheroes'); }; const fetchFriends = () => { return axios.get('http://localhost:4000/friends'); }; const ParallelQuriesPage = () => { const { data: superHeroes } = useQuery('super-heroes', fetchSuperHeroes); const { data: friends } = useQuery('friends', fetchFriends); return <div>Parallel Quries Page</div>; }; export default ParallelQuriesPage;
Dynamic Parallel Queries
If the number of queries you need to execute is changing from render to render, you cannot use manual querying as that would violate the rules of hooks. In other words, simply invoking
useQuery
multiple times is not sufficient for dynamic parallel queries. To cater to this specific scenario, react query provides another hook calleduseQueries
.import axios from 'axios'; import { useQueries } from 'react-query'; const fetchSuperHero = (heroId: number) => { return axios.get(`http://localhost:4000/superheroes/${heroId}`); }; const DynamicParallelPage = ({ heroIds }: { heroIds: number[] }) => { const queryResults = useQueries( heroIds.map((id) => ({ queryKey: ['super-hero', id], queryFn: () => fetchSuperHero(id), })) ); return <div>Dynamic Parallel Page</div>; }; export default DynamicParallelPage;
Dependent Queries
You need the queries to execute sequentially. The situation arises when you have one query dependent on the results of another query.
import axios from 'axios'; import { useQuery } from 'react-query'; type Props = { email: string; }; const fetchUserByEmail = (email: string) => { return axios.get(`http://localhost:4000/users/${email}`); }; const fetchCoursesByChannlId = (channelId: number) => { return axios.get(`http://localhost:4000/channels/${channelId}`); }; const DepentQueriesPage = ({ email }: Props) => { const { data: user } = useQuery(['user', email], () => fetchUserByEmail(email)); const channelId = user?.data.channelId; const { data: courses } = useQuery(['courses', channelId], () => fetchCoursesByChannlId(channelId), { enabled: !!channelId, }); return <div>Depent Queries Page</div>; }; export default DepentQueriesPage;
Initial Query Data
import axios, { AxiosResponse } from 'axios'; import { useQuery, useQueryClient } from 'react-query'; import { Hero } from '../typings/Heroes'; const fetchSuperHero = ({ queryKey }: { queryKey: string[] }) => { const heroId = queryKey[1]; return axios.get(`http://localhost:4000/superheroes/${heroId}`); }; export const useSuperHeroData = (heroId: string) => { const queryClient = useQueryClient(); return useQuery(['super-hero', heroId], fetchSuperHero, { initialData: () => { const hero = queryClient .getQueryData<AxiosResponse<Hero[]>>('super-heroes') ?.data?.find((hero) => hero.id === parseInt(heroId)); if (hero) { return { data: hero, } as AxiosResponse; } else { return undefined; } }, }); };
Paginated Queries
If we specify
keepPreviousData
and set it totrue
, we add query will maintain the data from the last successful fetch while the new data is being requested.import axios from 'axios'; import { useState } from 'react'; import { useQuery } from 'react-query'; const fetchColors = (pageNumber: number) => { return axios.get(`http://localhost:4000/colors?_limit=2&_page=${pageNumber}`); }; const PaginatedQueriesPage = () => { const [pageNumber, setPageNumber] = useState(1); const { isLoading, isError, error, data } = useQuery(['colors', pageNumber], () => fetchColors(pageNumber), { keepPreviousData: true, }); if (isLoading) { return <h2>Loading...</h2>; } if (isError) { return <h2>{error as string}</h2>; } return ( <div> {data?.data.map((color: { id: number; label: string }) => ( <div key={color.id}> <h2> {color.id}. {color.label} </h2> </div> ))} <div> <button onClick={() => setPageNumber(pageNumber - 1)} disabled={pageNumber === 1}> Prev page </button> <button onClick={() => setPageNumber(pageNumber + 1)} disabled={pageNumber === 4}> Next page </button> </div> </div> ); }; export default PaginatedQueriesPage;
Infinite Queries
import axios from 'axios'; import { Fragment } from 'react'; import { useInfiniteQuery } from 'react-query'; const fetchColors = ({ pageParam = 1 }) => { return axios.get(`http://localhost:4000/colors?_limit=2&_page=${pageParam}`); }; const InfiniteQueries = () => { const { isLoading, isError, error, data, hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = useInfiniteQuery(['colors'], fetchColors, { getNextPageParam: (_lastPage, pages) => { if (pages.length < 4) { return pages.length + 1; } else { return undefined; } }, }); if (isLoading) { return <h2>Loading...</h2>; } if (isError) { return <h2>{error as string}</h2>; } return ( <> <div> {data?.pages.map((group, i) => ( <Fragment key={i}> {group.data.map((color: { id: number; label: string }) => ( <h2 key={color.id}> {color.id}. {color.label} </h2> ))} </Fragment> ))} <div> <button disabled={!hasNextPage} onClick={() => fetchNextPage()}> Load more </button> </div> </div> <h2>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</h2> </> ); }; export default InfiniteQueries;
Mutations
It's time to focus on the data posting aspect. That is sending data from your application to any backend. In react query unlike queries, mutations are what we use to create update or delete data.
And this purpose similar touseQuery
, the library provides auseMutation
hook.src/components/RQSuperHeroes.page.tsx
// ... const RQSuperHeroesPage = () => { const [name, setName] = useState(''); const [alterEgo, setAlterEgo] = useState(''); const { mutate: addHero, isLoading, isError, error } = useAddSuperHeroData(); const handleAddHeroClick = () => { const hero = { name, alterEgo }; addHero(hero); }; if (isLoading) { return <h2>Loading...</h2>; } if (isError) { return <h2>{error.message}</h2>; } return ( <> <h2>RQ Super Heroes Page</h2> <div> <input type="text" value={name} onChange={(e) => setName(e.target.value)} /> <input type="text" value={alterEgo} onChange={(e) => setAlterEgo(e.target.value)} /> <button onClick={handleAddHeroClick}>Add Hero</button> </div> </> ); }; export default RQSuperHeroesPage;
src/hooks/useSuperHeroesData.ts
const addSuperHero = (hero: { name: string, alterEgo: string }) => { return axios.post('http://localhost:4000/superheroes', hero); }; export const useAddSuperHeroData = () => { return useMutation(addSuperHero); };
Query Invalidation
Before we have to manually refetch the superhero list by clicking the fetch heroes buton. This is because as soon as we add a new hero. The super heroes query data is out of date. Wouldn't it be nice if we could tell react query to automatically refetch the superheroes query as soon as the mutation succeeds. Well it definitely would be nice and react query makes it really simple to achieve that. The feature is called query invalidation.
src/hooks/useSuperHeroesData.ts
const addSuperHero = (hero: { name: string, alterEgo: string }) => { return axios.post('http://localhost:4000/superheroes', hero); }; export const useAddSuperHeroData = () => { const queryClient = useQueryClient(); return useMutation(addSuperHero, { onSuccess: () => { queryClient.invalidateQueries('super-heroes'); }, }); };
Handling Mutation Response
If you click on the post request, you can see in the response we have the new hero object being returned. It is pretty common for the new object to be automatically returned in the response of the mutation. So instead of refetching a query for this item and wasting a network call for data that we already have, we can take advantage of the object returned by the mutation function and immediately update the existing query with the new data. In similar words, we can use the add superhero mutation response to update the superheroes query data. Thereby saving an additional network request.
src/hooks/useSuperHeroesData.ts
const addSuperHero = (hero: { name: string, alterEgo: string }) => { return axios.post('http://localhost:4000/superheroes', hero); }; export const useAddSuperHeroData = () => { const queryClient = useQueryClient(); return useMutation(addSuperHero, { onSuccess: (data) => { queryClient.setQueryData('super-heroes', (oldQueryData: any) => { return { ...oldQueryData, data: [...oldQueryData.data, data.data], }; }); }, }); };