나는 React Query를 좋아한다. React Query가 React앱에서 비동기 상태를 다루는 방식을 단순화하기 때문이다.
하지만 가끔 서버에서 데이터를 페칭해오는 것처럼 "간단한" 작업에는 React Query가 필요하지 않다고 주장하는 글들을 볼 수 있다.
React Query가 제공하는 모든 추가 기능이 필요한 것은 아니니까, useEffect 안에서 fetch를 사용하는 것만으로도 충분할 때 굳이 서드파티 라이브러리를 추가하고 싶지 않다는 것이다.
이는 어느 정도 맞는 말이다.
React Query는 캐싱, retry, polling, 데이터 동기화, prefetching 등 많은 기능을 제공한다.
이러한 기능들이 필요하지 않다면 괜찮지만, 그렇다고 해서 React Query를 사용하지 말아야 한다고 생각하지는 않는다.
데이터 fetching이나 mutation을 위한 내장 기능을 제공하는 프레임워크를 사용하는 경우엔 React Query가 필요하지 않을 수 있다.
useEffect를 이용해 fetch하는 아주 전형적인 코드를 살펴보고, 왜 이런 상황에서도 React Query를 사용하는게 좋은지 알아보자.
function Bookmarks({ category }) {
const [data, setData] = useState([])
const [error, setError] = useState()
useEffect(() => {
fetch(`${endpoint}/${category}`)
.then(res => res.json())
.then(d => setData(d))
.catch(e => setError(e))
}, [category])
// Return JSX based on data and error state
}
이 코드가 괜찮아 보이는가? 추가 기능이 필요하지 않을까?
이 10줄의 코드에는 5개의 버그가 숨어있다.
잠시 시간을 내서 버그들을 모두 찾을 수 있는지 확인해보자
힌트: 의존성 배열은 문제없다.
1. Race Condition 🏎 🏇
데이터 페칭할때 리액트 공식문서에서 React Query와 같은 라이브러리를 사용하도록 권장하는 데에는 이유가 있다.
데이터 요청은 사소할수도 있지만, 그 요청과 관련한 상태를 앱에서 예상 가능하게 다루는 것은 절대 사소하지 않다.
위 effect는 `category`에 의존성을 가지기 때문에, `category`가 변경될 때마다 다시 fecth한다.
이는 의도에 맞는 올바른 동작이지만, 각 요청에 대한 응답이 순서대로 도착하지 않을 수도 있다.
즉 `category`를 `books` 에서 `movies`로 변경했을때 `movies`에 대한 응답이 `books`에 대한 응답보다 먼저 도착하면, 컴포넌트에 잘못된 데이터가 표시될 수 있다.
React 문서에서는 useEffect에서 cleanup 함수와 ignore 플래그를 이용하여 이 문제를 해결한다.
// category prop이 변경되어 컴포넌트가 rerender 될 때 클린업 함수가 호출된다.
// (데이터를 가져오는 동안에 rerender, unmount 되었을시 그 데이터는 보여줄 필요가 없다)
useEffect(() => {
let rerendered = false;
getPost(id)
.then((res) => res.json())
.then((d) => {
if (!rerendered) setData(d);
})
.catch((e) => {
if (!rerendered) {
setError(e);
}
});
return () => {
rerendered = true;
};
}, [id]);
2. Loading state 🕐
페칭이 진행되는 동안 대기중인 UI를 보여주지 않고 있다.
`isLoading` 상태를 추가하고 핸들링해보자.
// + 로딩 상태 핸들링
export function PostWithLoading({ id }: Props) {
const [data, setData] = useState<PostType>({ id: 0, title: '' });
const [error, setError] = useState();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let rerendered = false;
setIsLoading(true);
getPost(id)
.then((res) => res.json())
.then((d) => {
if (!rerendered) setData(d);
})
.catch((e) => {
if (!rerendered) {
setError(e);
}
})
.finally(() => {
setIsLoading(false);
});
return () => {
rerendered = true;
};
}, [id]);
if (isLoading) return <div>Loading...</div>;
return (
<>
<div>+ 로딩 상태 핸들링</div>
<div>{data.title}</div>
</>
);
}
3. Empty state 🗑️
`data` 상태를 빈 배열`[]`로 초기화하는건 `undefined` 여부를 항상 체크하지 않아도 된다는 점에서 좋은 아이디어로 보인다.
그러나 가져온 데이터가 빈 배열이라면 어떨까? "아직 데이터가 없음"과 "실제로 데이터가 없음"을 구분할 방법이 없다.
로딩 상태가 도움이 되지만, 여전히 `undefined`로 초기화하는게 좋다.
// + data = undefined로 초기화
export function InitiateDataUndefined({ id }: Props) {
const [data, setData] = useState<PostType>(); // undefined
const [error, setError] = useState();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let rerendered = false;
setIsLoading(true);
getPost(id)
.then((res) => res.json())
.then((d) => {
if (!rerendered) setData(d);
})
.catch((e) => {
if (!rerendered) {
setError(e);
}
})
.finally(() => {
setIsLoading(false);
});
return () => {
rerendered = true;
};
}, [id]);
if (isLoading) return <div>Loading...</div>;
if (!data) return <div>no data</div>;
return (
<>
<div>data undefined로 초기화</div>
<div>{data.title}</div>
</>
);
}
4. 다음 fetch때 이전의 data 상태 또는 error 상태가 초기화되지 않는다. 🔄
첫번째 fetch가 실패(setError)하고 다음 fetch가 성공(setData)했을때, 첫번째 fetch때 setError한 error가 초기화되지 않는 문제가 있다.
data: dataFromCurrentCategory
error: errorFromPreviousCategory
저 상태들을 가지고 어떻게 렌더링하냐에 따라 다르지만, 이전 fetch의 결과인 error 상태가 남아있다는건 문제가 된다.
return (
<>
<div>서로 다른 요청으로 받은 error(fetch2)상태와 data(fetch1)상태가 중첩될 수 있다.</div>
{error && <div>Error!!</div>}
<div className="text-blue-600">data: {data.title}</div>
<div className="text-red-600">error: {error ? 'true' : 'false'}</div>
</>
);
오류와 데이터를 모두 렌더링하면 오래된 정보도 렌더링하게 된다
이 문제를 해결하려면 id가 변경될 때 error 상태를 재설정해야 한다.
export const getPostWithError = async (id: number) => {
if (id === 2) await new Promise((resolve) => setTimeout(resolve, 1000));
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
// if (!response.ok) return new Error('Failed to fetch');
if (id === 2) throw new Error('Failed to fetch');
return response.json();
};
// data, error 상태를 초기화해서 이전 fetch 결과 흔적을 지운다.
export function DataAndErrorReset({ id }: Props) {
const [data, setData] = useState<PostType>();
const [error, setError] = useState<boolean>();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let rerendered = false;
setIsLoading(true);
getPostWithError(id)
.then((d) => {
if (!rerendered) {
setData(d);
setError(false); // error 초기화
}
})
.catch((e) => {
if (!rerendered) {
setError(true);
setData(undefined); // data 초기화
}
})
.finally(() => {
setIsLoading(false);
});
return () => {
rerendered = true;
};
}, [id]);
if (isLoading) return <div>Loading...</div>;
// if (!data) return <div>no data</div>;
return (
<>
<div>다음 fetch 시 이전 fetch 결과 흔적인 data, error 상태를 초기화한다.</div>
{error && <div>Error!!</div>}
<div className="text-blue-600">data: {data?.title}</div>
<div className="text-red-600">error: {error ? 'true' : 'false'}</div>
</>
);
}
5. StrictMode에서 두 번 실행된다. 🔥🔥
이건 버그는 아니지만 성가시다.
ref를 이용해 해결할 수 있지만 그 코드를 작성할 가치가 없다.
(영진: 이건 그냥 읽고 넘어가면 될듯)
"데이터를 가져오기만 하면 되는데 얼마나 어렵겠어?" 라고 생각하며 useEffect를 작성한 결과 거대한 스파게티 훅이 만들어졌다.
데이터 페칭은 간단하다. 다만 비동기 상태관리는 어렵다.
React Query는 데이터 페칭 라이브러리가 아니다.
React Query는 비동기 상태 관리 매니저이다.
따라서 앱에서 상태를 예측 가능하게 잘 다루고 싶을때 React Query를 쓰면 좋다.
사실 나는 React Query를 사용하기 전에는 `ignore` flag를 작성해본 적이 없다. 아마 여러분도 마찬가지일거다.
React Query를 사용하면 위 코드들은 이렇게 바뀐다:
// 위 기능들은 react query에 다 구현되어있다.
export function WithReactQuery({ id }: Props) {
const { data, isLoading, error } = useQuery({
queryKey: ['posts', id],
queryFn: () => getPostWithError(id),
});
if (isLoading) return <div>Loading...</div>;
// if (!data) return <div>no data</div>;
return (
<>
<div> 위 기능들은 react query에 다 구현되어있다.</div>
{error && <div>Error!!</div>}
<div className="text-blue-600">data: {data?.title}</div>
<div className="text-red-600">error: {error ? 'true' : 'false'}</div>
</>
);
}
아직 React Query를 사용하고 싶지 않다고 생각이 든다면 일단 다음 프로젝트에서 사용해보길 권하고 싶다.
엣지 케이스를 더 잘 다룰 수 있고 유지 보수도 확장도 더 쉬워질 것이다.
내 기준 어려운 페칭 상태들
1. 페칭 데이터 경쟁 상태 (race condition)
2. 페칭 로딩 상태 (isLoading)
3. 이전 페칭 상태들의 중첩 (data, error)
-> React query는 이를 잘 추상화해둠
이 글은 https://tkdodo.eu/blog/why-you-want-react-query#inject-comments 을 읽으며 제 입맛대로 해석하며 정리한 글입니다.
블로그 주인의 생각이 추가되어있고, 틀린 내용이 있을 수 있습니다.
'React Query' 카테고리의 다른 글
8. 타입 안전한 React Query (0) | 2024.08.06 |
---|---|
7. Query Options API (0) | 2024.07.31 |
5. React Query의 Status (0) | 2024.02.21 |
4. 효율적으로 React Query Key 선언하기 (5) | 2024.02.14 |
3. React Query와 에러핸들링 (2) | 2024.02.08 |