about 22 hours ago
Recently, while building a Network Debugger, I ended up adding Network Throttling functionality as well. This led me to handle exceptions in slow internet and offline environments using ErrorBoundary
+ Suspense
+ @tanstack/react-query
. During this process, I experienced some inconvenience with the existing logic related to query reset. I'd like to share the various approaches I tried to solve this and the surprisingly simple yet super cool solution I discovered.
"Loading..."
, "An error occurred."
When building React applications, we constantly deal with numerous asynchronous requests and their corresponding states (loading, success, error). Many developers have managed states like isLoading and isError directly using useState.
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
useEffect(() => {
const fetchData = async () => {
try {
setIsError(false);
setIsLoading(true);
const result = await api.get('/data');
setData(result);
} catch (e) {
setIsError(true);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error occurred!</div>;
return <div>{data.title}</div>;
While this code is familiar, such boilerplate repeats in every component, and business logic becomes intertwined with UI state management, quickly making the code complex.
However, with React 18 and TanStack Query (v4+), we can end this battle in a much more declarative
and super cool way. In this article, let's deeply explore how to handle errors by combining Suspense
, ErrorBoundary
, and React Query
, and especially dig into the internal principles of how error reset works like magic.
To break away from the imperative approach, we need three key elements:
ErrorBoundary
: Handles error states. It catches JavaScript errors occurring in the child component tree and shows fallback UI when errors occur.Suspense
: Handles loading states. It waits until child component data loading (Promise) is complete and shows fallback UI during the waiting period.React Query (useSuspenseQuery)
: Handles data fetching and serves as the bridge between the above two elements.While fetching data, it throws a Promise to activate Suspense
.
When data fetching fails, it throws an error to activate ErrorBoundary
.
Thanks to this combination, our components can focus solely on the success case.
Although I actually created an AsyncBoundary
component that combines ErrorBoundary
+ Suspense
, I'll explain it as follows for better understanding.
// MyComponent.jsx
function MyComponent() {
// No longer need to worry about loading and error states!
const { data } = useSuspenseQuery({ queryKey: ['myData'], queryFn: fetchData });
// This code only runs when data arrives successfully.
return <div>{data.title}</div>;
}
// App.jsx
import { ErrorBoundary } from 'react-error-boundary';
function App() {
return (
<ErrorBoundary fallbackRender={({ error }) => <div>Error: {error.message}</div>}>
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
</ErrorBoundary>
);
}
Really clean, isn't it? But here's one question: when an error occurs and ErrorBoundary's fallback UI is shown, how can users retry?
I didn't actually implement it exactly like below, but used pre-created options with QueryOptions in the format queryClient.refetchQueries(queryOptions())
.
However, the conclusion is that you need to know information about queries executed in components under Suspense
. The example below is a simple case, but imagine if there were multiple variables to include in the queryKey - the situation becomes quite complex.
function App() {
const queryClient = useQueryClient();
return (
<ErrorBoundary
onReset={() => queryClient.refetchQueries({ queryKey: ['myData'] })}
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<h2>Oops, something went wrong. 😢</h2>
<p>{error.message}</p>
{/* 3. This button works the magic! */}
<button onClick={resetErrorBoundary}>Try Again</button>
</div>
)}
>
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
</ErrorBoundary>
);
}
What emerges to implement the "retry" functionality is QueryErrorResetBoundary
. This component provides a reset mechanism that makes React Query "forget" the error state and execute queries again.
The usage is simple. Just wrap the existing ErrorBoundary
with QueryErrorResetBoundary
and pass the reset function to ErrorBoundary's onReset prop.
import { QueryErrorResetBoundary } from '@tanstack/react-query';
function App() {
return (
// 1. Wrap with QueryErrorResetBoundary.
<QueryErrorResetBoundary>
{/* 2. Pass the reset function to the onReset prop. */}
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<h2>Oops, something went wrong. 😢</h2>
<p>{error.message}</p>
{/* 3. This button works the magic! */}
<button onClick={resetErrorBoundary}>Try Again</button>
</div>
)}
>
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
Now when users click the "Try Again" button, the query that had an error will be executed again. But how is this possible? How does QueryErrorResetBoundary give commands to useSuspenseQuery saying "Reset!"?
To conclude first, QueryErrorResetBoundary doesn't give direct commands to queries. Instead, it uses a very smart approach where it broadcasts a "reset signal" and queries interpret that signal themselves and act accordingly.
This interaction consists of clear role division between two parties.
QueryErrorResetBoundary
uses React's Context API
to provide simple state and functions within its area.
This component's only mission is to announce "Reset signal is on!" to its entire area when the "Try Again" button is pressed. It doesn't know which queries are in error state and doesn't care.
The useBaseQuery
hook, which forms the foundation of useQuery
and useSuspenseQuery
, internally knows both pieces of information:
And useBaseQuery
combines these two pieces of information every time it renders to decide "Should I throw an error now?" This decision is made in a utility function called getHasError.
// Core logic in errorBoundaryUtils.ts
const getHasError = ({ result, errorResetBoundary }) => {
return (
// 1. I'm in error state, and
result.isError &&
// 2. Reset signal isn't on yet
!errorResetBoundary.isReset()
// ... other conditions
);
};
// Core logic in useBaseQuery.ts
if (getHasError({ result, errorResetBoundary })) {
throw result.error;
}
This code is the secret behind all the magic.
isError
is true and reset signal isReset
is false. Therefore getHasError
returns true, and the query throws an error to activate Error Boundary
.getHasError
returns false due to the !isReset() condition. Therefore the query no longer throws errors.When useBaseQuery
stops throwing errors, React continues component rendering. At this point, the query observer realizes "Hey, I was in error state but I have no data? I should fetch again!" and naturally starts refetching according to its original logic.
This is like a traffic light. The reset signal only changes red light to green light; it doesn't push the car. The car sees the green light and starts moving by itself.
React Query's Suspense
and ErrorBoundary
support has value beyond simply reducing loading and error state code. By perfectly integrating UI state management related to data fetching into React's declarative paradigm
, it allows developers to focus more on business logic.
The interaction between QueryErrorResetBoundary
and useBaseQuery
may seem complex, but its core follows React's philosophy of "sharing state and reacting autonomously instead of direct commands"
. Now, how about we stop fighting with imperative error handling and create more robust and predictable applications through this super cool pattern?
When I was a sole proprietor for about 2 years, I focused more on business aspects, trying new ideas, and development was a means to validate them rather than pursuing technical depth. Coming back to work at a company called Wrtn and focusing on my role as a developer, I find it a really good experience to continuously explore technical depth.
Even when I return to individual business or startups in the future, just as a child doesn't forget how to ride a bicycle as they grow up, I believe these experiences will become good roots for me.