상태(State)
모던 웹프론트엔드 개발
- UI/UX의 중요성과 함께 프로덕트 규모가 많이 커지고 프론트엔드에서 수행하는 역할이 늘어났다.
- 즉,관리하는 상태가 많아지고 상태관리의 필요성이 중요해졌다.
상태란?
- 주어진 시간에 대해 시스템을 나타내는 것으로 언제든지 변경될 수 있다.
- 즉 문자열, 배열, 객체 등의 형태로 응용 프로그램에 저장된 데이터
- 개발자 입장에선 관리해야하는 데이터들이라고 볼 수 있다.
상태 관리는?
- 상태를 관리하는 방법에 대한 것 ➡️ 프로덕트가 커짐에 따라 어려움도 커진다.
- 상태들은 시간에 따라 변화한다.
- React에선 단방향 바인딩이므로 Props Drilling 이슈도 존재한다.
- Redux와 MobX 같은 라이브러리를 활용해 해결하기도 한다.
Client State & Server State
Client State와 Server State Key Point는 데이터의 Ownership이 있는 곳이다.
- Client State : Ownership이 Client에 있다.
- Client에서 소유하며 온전히 제어가능하다.
- 초기값 설정이나 조작에 제약사항이 없다.
- 다른 사람들과 공유되지 않으며 Client 내에서 UI/UX 흐름이나 사용자 인터랙션에 따라 변할 수 있다.
- 항상 Client 내에서 최신 상태로 관리된다. - Server State : Ownership이 Server에 있다.
- Client에서 제어하거나 소유되지 않는 원격의 공간에서 관리되고 유지된다.
- Fetching이나 Updating에 비동기 API가 필요하다.
- 다른 사람들과 공유되는 것으로 사용자가 모르는 사이에 변경될 수 있다.
- 신경 쓰지 않는다면 잠재적으로 "out of date"가 될 가능성을 지닌다.
React Query (Feat. TanStack Query)
React Query와 Taqnsk Query는 둘 다 React 애플리케이션에서 데이터를 가져오고 관리하는 라이브러리다.
그러나 두 라이브러리에는 몇 가지 차이점이 있다.
- 데이터 가져오기 방법 React Query는 데이터를 가져올 때 기본적으로 REST API 호출을 사용한다. 그러나 다른 데이터 소스를 지원하기 위해 플러그인 아키텍처를 제공한다. 반면, TanStack Query는 REST API 호출, GraphQL 쿼리 및 Firebase Realtime Database와 같은 데이터 소스를 지원한다.
- 데이터 캐싱 및 관리 React Query는 내장된 캐시 및 리패칭(Re-fetching) 로직을 사용하여 성능을 향상시킨다. 또한, 컴포넌트 수준의 데이터 관리를 지원하며, 데이터 변이를 자동으로 처리한다. 반면, TanStack Query는 캐싱 및 리패칭 로직이 없으며, 데이터 업데이트를 수동으로 처리해야한다.
- 유연성 및 구성 가능성 React Query는 구성 가능성이 높으며, 필요한 경우 커스텀 로직을 추가할 수 있다. 또한, React Query Devtools를 제공하여 쉽게 디버깅할 수 있다. TanStack Query는 상대적으로 덜 유연하며, 사용자 지정 로직을 추가하는 데 필요한 작업이 더 많다.
- 프로젝트 규모 React Query는 작은 및 중간 규모의 프로젝트에서 적합하다. 반면, TanStack Query는 대규모 및 복잡한 프로젝트에서 더 적합한다.
결론적으로, React Query는 단순하고 쉽게 사용할 수 있으며, 기본적인 캐싱 및 리패칭 로직을 갖추고 있다.
반면, TanStack Query는 다양한 데이터 소스를 지원하며, 더 많은 구성 가능성을 제공한다.
프로젝트 규모 및 데이터 소스에 따라 선택할 수 있는 라이브러리가 달라질 수 있다.
react-query의 상태
State | Description |
✅ fresh | 새롭게 추가된 쿼리 & 만료되지 않은 쿼리 ➜ 컴포넌트가 마운트, 업데이트되어도 데이터 재요청 ❌ |
✅ fetching | 요청 중인 쿼리 |
✅ stale | 만료된 쿼리 ➜ 컴포넌트가 마운트, 업데이트되면 데이터 재요청 ⭕️ |
✅ inactive | 비활성화된 쿼리 ➜ 특정 시간이 지나면 가비지 컬렉터에 의해 제거 |
react-query 사용방법
설치 및 설정
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>,
);
react-query의 샘플코드
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(데이터 가져오는 법)
useQuery Hook으로 수행되는 Query 요청은 HTTP메소드 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가 같더라도 캐싱을 별도로 관리한다.
data request
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
options | Description |
✅ cacheTime | 언마운트된 후 어느 시점까지 메모리에 데이터를 저장하여 캐싱할 것인지를 결정 |
✅ staleTime | 쿼리가 fresh 상태에서 stale 상태로 전환되는 시간 |
✅ refetchOnMount | 컴포넌트 마운트시 새로운 데이터 패칭 |
✅ refetchOnWindowFocus | 브라우저 클릭 시 새로운 데이터 패칭 |
✅ refetchInterval | 지정한 시간 간격만큼 데이터 패칭 |
✅ refetchIntervalInBackground | 브라우저에 포커스가 없어도 refetchInterval에서 지정한 시간 간격만큼 데이터 패칭 |
✅ enabled | 컴포넌트가 마운트 되어도 데이터 패칭 ❌ |
✅ onSuccess | 데이터 패칭 성공 |
✅ onError | 데이터 패칭 실패 |
✅ select | 데이터 패칭 성공 시 원하는 데이터 형식으로 변환 가능 |
cacheTime
언마운트된 후 어느 시점까지 메모리에 데이터를 저장하여 캐싱할 것인지를 결정
- 기본값 : 30000 -> 5분
// cacheTime을 3초로 설정
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
cacheTime: 3000,
});
staleTime
쿼리가 fresh 상태에서 stale 상태로 전환되는 시간
- 기본값 : 0
- fresh 상태에서는 컴포넌트가 마운트, 업데이트가 되어도 재요청을 보내지 않으므로 API 요청 횟수를 줄일 수 있다.
- 보통 쉽게 변하지 않는 컴포넌트에 한해서 staleTime을 지정한다.
// staleTime을 3초로 설정
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
staleTime: 3000,
});
refetchOnMount
컴포넌트 마운트시 새로운 데이터 패칭
- 기본값 : true
- false로 설정할 경우 마운트시 새로운 데이터를 가지고 오지 않는다.
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
refetchOnMount: true, // or false
});
refetchOnWindowFocus
브라우저 클릭 시 새로운 데이터 패칭
- 기본값 : true
- flase로 설정할 경우 브라우저가 포커스 되어도 데이터를 가지고 오지 않는다.
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
refetchOnWindowFocus: true, // or false
});
refetchInterval
지정한 시간 간격만큼 데이터 패칭
- 기본값 : 0
- 브라우저에 포커스가 없을 때 실행되지 않는다.
// 2초 간격으로 데이터 패칭
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
refetchInterval: 2000,
});
refetchIntervalInBackground
브라우저에 포커스가 없어도 refetchInterval에서 지정한 시간 간격만큼 데이터 패칭
- 기본값 : false
// 2초 간격으로 데이터 패칭
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
refetchInterval: 2000,
refetchIntervalInBackground: true,
});
enabled
컴포넌트가 마운트 되어도 데이터 패칭 ❌
- 기본값 : true
- useQuery의 반환값 중 refetch를 활용하여 데이터 패칭을 할 수 있다.
const { data, isLoading, refetch } = useQuery('super-heroes', fetchSuperHeroes, {
enabled: false,
});
return (
<button onClick={ refetch }>Fetch Button</button>
)
onSuccess
데이터 패칭 성공
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
onSuccess: (data) => {
console.log('데이터 요청 성공', data)
}
});
onError
데이터 패칭 실패
const { data, isLoading } = useQuery('super-heroes', fetchSuperHeroes, {
onError: (error) => {
console.log('데이터 요청 실패', error)
}
});
select
데이터 패칭 성공 시 원하는 데이터 형식으로 변환 가능
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(데이터 변경 및 삭제 방법)
useMutation Hook으로 수행되는 Mutation 요청은 HTTP 메소드 POST, PUT, DELETE 요청과 같이 서버에 Side Effect를 발생시켜 서버의 상태를 변경시킬 때 사용한다.
- useMutation Hook의 첫번째 파라미터는 이 Mutation 요청을 수행하기 위한 Promise를 Return 하는 함수이며, useMutation의 return 값 중 mutate(또는 mutateAsync) 함수를 호출하여 서버에 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);
}}
/>
);
}
- useQuery와 같은 반환값을 받으며 mutate 메소드가 추가된다.
- mutate 메소드를 이용하면 API 요청 함수를 호출하여 요청이 이루어진다.
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 실패 상관 없이 실행
},
})
React Query를 이용한 비동기 데이터 동기화 기능을 갖춘 Todo List 예제
// 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;
Reference