I stopped using the useEffect hook to fetch data. React Query works, but is it easier?

Leejjon
12 min readApr 29, 2023

Just like many, when I started to build React.js web apps I used the useEffect hook to fetch data because the (now old) documentation said so.

A few months ago I stumbled on this article below about the useEffect hook and data fetching:

That article has valid points. I could immediately jump to its conclusion and switch to React Query, Vercel’s SWR or the Apollo HTTP client.

However, it’s usually a good learning experience to not jump to solutions. Instead you should first encounter the problems, isolate them in a hello world project, understand them and look for the best solution. If you jump to a framework that promises lots of solutions for problems you’re not even sure about whether you’re going to have them, you might add a lot complexity.

So read on if you want to take a look at the problem(s) with me!

My starting point

Here is an example of a React App created with create-react-app. It is fetching football matches from api.football-data.org with the useEffect hook and display them in a React component:

import React, {useEffect, useState} from 'react';
import './App.css';
import {Match} from "./model/Match";
import {getMatchesFromApi} from "./api/ApiCall";

function App() {
const [error, setError] = useState<string | undefined>(undefined);
const [matches, setMatches] = useState<Array<Match>>([]);
useEffect(() => {
if (matches.length === 0) {
getMatchesFromApi(2021)
.then(matches => setMatches(matches))
.catch((e) => setError("Could not fetch the matches."));
}
}, [matches, setMatches]);
if (error) {
return (
<div className="App">{error}</div>
);
}
return (
<div className="App">
<ul>
{matches.map((match, index) => {
return (
<li key={index}>
<p>{match.homeTeam.name}&nbsp;-&nbsp;{match.awayTeam.name}</p>
</li>
);
})}
</ul>
</div>
);
}

export default App;

This is how the app works when you run it locally:

You can try this code locally if you clone it from GitHub. You will need to create a .env.local file and put your own (free) api key from football-data.org in there.

This code has a few small problems that I could initially live with for hobby apps:

  • If you run this locally with strict mode on (default in create-react-app) it will fetch the data twice. You can work around it by removing the <React.StrictMode> tags in your index.tsx file.
  • It needs some more code to show a spinner while the data is fetching.
  • It only starts fetching the data when the component has loaded.

Regarding that last thing, let’s take a look at the performance.

In the waterfall from the Chrome screenshot, we see that the React app takes under 400ms to download. The content of the DOM is only loaded after ~550ms. That means it takes less than 200ms to start up React and all our components. Then it takes another ~800 ms to load the matches from the football API.

Will React Query fetch data faster?

I decided replace the useEffect hook with the useQuery hook from React Query, as it seems React Query is being recommended a lot. I’ve prepared a separate branch of the same code, that uses this useQuery hook instead.

In the index.tsx I’m adding a few things:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {getMatchesFromApi} from "./api/ApiCall";

const matchesQueryClient = new QueryClient();

export const matchesQuery = () => (
{
queryKey: ["matches"],
queryFn: async () => { return await getMatchesFromApi(2021)},
}
);

const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<QueryClientProvider client={matchesQueryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

reportWebVitals();

And in the App.tsx we replace the useEffect hook with a useQuery hook:

import React from 'react';
import './App.css';
import {Match} from "./model/Match";
import {matchesQuery} from "./index";
import {useQuery} from "@tanstack/react-query";

function App() {
const {data} = useQuery({
...matchesQuery(),
refetchOnWindowFocus: false,
refetchOnMount: false,
cacheTime: 5000,
staleTime: 4000,
retry: false,
});
const matches: Array<Match> = data ?? [];
return (
<div className="App">

<ul>
{matches.map((match, index) => {
return (
<li key={index}>
<p>{match.homeTeam.name}&nbsp;-&nbsp;{match.awayTeam.name}</p>
</li>
);
})}
</ul>
</div>
);
}

export default App;

When we run this thing, it works as expected. However the entire call takes even longer!

I’m not sure if adding React Query to our application increased the bundle size and thus slows things down. Let me know in the comments if you think there is another reason.

My conclusion for now is:

  • React Query does not fetches data faster than if you would do it from a useEffect hook. It might even use the useEffect hook under the hood.
  • Use React Query if you need the extra features it offers for data fetching, such as caching.

Fetching data based on react-router routes

The above app is simple. It has one page that always fetches the matches data. Most web apps have multiple pages and use a routing library like React Router. In the real world different pages would usually fetch different data. I prepared a branch with multiple pages and the routes defined with react router.

Let’s create the following routes:

  • / The home page
  • /matches A page that displays matches
  • /matches2 Another page that displays matches

We need to move the matches logic to a new file called Matches.tsx in a pages folder:

import {Match} from "../model/Match";
import React, {useEffect} from "react";
import {getMatchesFromApi} from "../api/ApiCall";

export interface MatchesProps {
matches: Array<Match>;
setMatches: (matches: Array<Match>) => void;
}

function Matches({matches, setMatches}: MatchesProps) {
useEffect(() => {
if (matches.length === 0) {
getMatchesFromApi(2021)
.then(matches => {
setMatches(matches);
setError(undefined);
})
.catch((e) => setError("Could not fetch the matches."));
}
}, [matches, setMatches]);

if (error) {
return (
<div className="App">{error}</div>
);
}

return (
<ul>
{matches.map((match, index) => {
return (
<li key={index}>
<p>{match.homeTeam.name}&nbsp;-&nbsp;{match.awayTeam.name}</p>
</li>
);
})}
</ul>
);
}

export default Matches;

Simply duplicate this Matches.tsx file to a Matches2.tsx file with almost identical code. Also create a nice Home.tsx:

function Home() {
return (
<div>Home sweet home.</div>
);
}

export default Home;

After installing react router with npm i react-router-dom @types/react-router-dom we can define the routes in App.tsx:

import React, {useState} from 'react';
import './App.css';
import {Match} from "./model/Match";
import {BrowserRouter, Link, Route, Routes} from "react-router-dom";
import Home from "./pages/Home";
import Matches from "./pages/Matches";
import Matches2 from "./pages/Matches2";

function App() {
const [error, setError] = useState<string | undefined>(undefined);
const [matches, setMatches] = useState<Array<Match>>([]);
return (
<div className="App">
<BrowserRouter>
<Link to="/">Home</Link><br/>
<Link to="/matches">Matches</Link><br/>
<Link to="/matches2">Matches2</Link><br/>
<Routes>
<Route path="/" element={<Home/>} />
<Route path="/matches" element={
<Matches matches={matches} setMatches={setMatches} error={error} setError={setError}/>
}/>
<Route path="/matches2" element={
<Matches2 matches={matches} setMatches={setMatches} error={error} setError={setError}/>
}/>
</Routes>
</BrowserRouter>
</div>
);
}

export default App;

Going to the home page no longer fetches the matches:

Navigating to /matches or /matches2 does trigger the fetch. If we navigate between them, no re-fetch is happening (which is nice and efficient).

The above performance is from when you do a hard refresh on the matches route. You can clone this version of the code here

React Router loaders

The article I mentioned above has a short mention about using React Router loaders instead of using the useEffect hook.

The React Router loader docs mention:

Each route can define a “loader” function to provide data to the route element before it renders.

Does this mean we can increase performance by letting a loader do the fetching before our element renders?

At first glance it looks as if you can simply add loaders to the Routes in the JSX:

import React from 'react';
import './App.css';
import {BrowserRouter, Link, Route, Routes} from "react-router-dom";
import Home from "./pages/Home";
import Matches from "./pages/Matches";
import Matches2 from "./pages/Matches2";
import {getMatchesFromApi} from "./api/ApiCall";

function App() {
return (
<div className="App">
<BrowserRouter>
<Link to="/">Home</Link><br/>
<Link to="/matches">Matches</Link><br/>
<Link to="/matches2">Matches2</Link><br/>
<Routes>
<Route path="/" element={<Home/>} />
<Route path="/matches" element={<Matches />}
loader={ async () => { return await getMatchesFromApi(2021)} } />
<Route path="/matches2" element={<Matches2 />}
loader={ async () => { return await getMatchesFromApi(2021)} } />
</Routes>
</BrowserRouter>
</div>
);
}

export default App;

In the Matches component you should be able to use the useLoaderData hook to retrieve the data from the loader:

import {Match} from "../model/Match";
import React from "react";
import {useLoaderData} from "react-router-dom";

function Matches() {
const matches: Array<Match> = useLoaderData() as Array<Match>;

return (
<ul>
{matches.map((match, index) => {
return (
<li key={index}>
<p>{match.homeTeam.name}&nbsp;-&nbsp;{match.awayTeam.name}</p>
</li>
);
})}
</ul>
);
}

export default Matches;

Unfortunately, if you try to run this code you’ll get the error useLoaderData must be used within a data router, as explained in this StackOverflow post.

To solve this, we have to use the createBrowserRouter function to define our Routes. This can be quite an overhaul if you have a lot of routes, let’s apply it on this test project so you get an idea.

Start with defining the router in index.tsx file:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import reportWebVitals from './reportWebVitals';
import {createBrowserRouter, RouterProvider} from "react-router-dom";
import App from "./App";
import Matches from "./pages/Matches";
import Matches2 from "./pages/Matches2";
import {getMatchesFromApi} from "./api/ApiCall";

const router = createBrowserRouter([
{
path: "/",
element: <App/>,
children: [
{
index: true,
element: <Home/>
},
{
path: "/matches",
element: <Matches/>,
loader: async () => { return await getMatchesFromApi(2021)}
},
{
path: "/matches2",
element: <Matches2 />,
loader: async () => { return await getMatchesFromApi(2021)}
}
]
}
]);

const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);

root.render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);

reportWebVitals();

Our App.tsx file will still contain the navigation Link elements, and also an <Outlet/> element in which the loaded page content will be loaded:

import React from 'react';
import './App.css';
import {Link, Outlet} from "react-router-dom";

function App() {
return (
<div className="App">
<Link to="/">Home</Link><br/>
<Link to="/matches">Matches</Link><br/>
<Link to="/matches2">Matches2</Link><br/>
<Outlet/>
</div>
);
}

export default App;

Now run the application again (find this version of the code here):

After using loaders

By fetching data inside the react router loaders we have successfully removed the useEffect clauses. And our code works, but the performance didn’t improve and we’ve lost a bit of control.

Pro’s:

  • No more duplicate fetching in strict mode locally.
  • The routes are defined in code, which could be a little bit more flexible. If you prefer to define your routes in JSX and still use loaders, you can use the createRoutesFromElements function.

Con’s:

  • We’ve had to refactor our entire routing, and performance didn’t improve. It might still improve your performance if you have very heavy rendering (due to many nested components), but in this simple example, it’s premature optimization.
  • We’ve lost the simple caching our useEffect & useState solution offered. Which means that browsing from /matches to /matches2 will do a re-fetch of the matches data.
  • We have to do an unsafe cast from the useLoaderData hook:
const matches: Array<Match> = useLoaderData() as Array<Match>;

If you look at the declaration in the react-router types, you’ll see that the hook doesn’t have a way to use the return type from the loader in the useLoaderData hook:

Improving it with React Query

I’d suggest to use React Query instead of passing raw fetch functions to React Router loaders.

The author of React Query has written a great guide to use React Query with React Router loaders, but I’ll show how to apply it on our example anyway. And of course, you can find the code on GitHub in a separate branch.

In the index.tsx, we need to define our query client, the query and the query loader:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import reportWebVitals from './reportWebVitals';
import {createBrowserRouter, RouterProvider} from "react-router-dom";
import App from "./App";
import Matches from "./pages/Matches";
import Matches2 from "./pages/Matches2";
import {getMatchesFromApi} from "./api/ApiCall";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {Match} from "./model/Match";
import Home from "./pages/Home";

const queryClient = new QueryClient();

export const matchesQuery = () => (
{
queryKey: ["matches"],
queryFn: async () => { return await getMatchesFromApi(2021)},
}
);

const matchesQueryLoader = async (queryClient: QueryClient): Promise<Array<Match>> => {
return await queryClient.ensureQueryData(matchesQuery());
};

const router = createBrowserRouter([
{
path: "/",
element: <App/>,
children: [
{
index: true,
element: <Home/>
},
{
path: "/matches",
element: <Matches/>,
// If there is an error in the loader, load the page anyway because it can render without data.
errorElement: <Matches/>,
loader: async () => { return matchesQueryLoader(queryClient) }
},
{
path: "/matches2",
element: <Matches2 />,
errorElement: <Matches2 />,
loader: async () => { return matchesQueryLoader(queryClient)}
}
]
}
]);

const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);

root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>
);

reportWebVitals();

In our Matches.tsx and Matches2.tsx add the useQuery hook to get the matches data:

import {Match} from "../model/Match";
import React from "react";
import {useQuery} from "@tanstack/react-query";
import {matchesQuery} from "../index";

function Matches() {
const {data, isError, isPaused} = useQuery({
...matchesQuery(),
refetchOnWindowFocus: false,
refetchOnMount: false,
cacheTime: 5000,
staleTime: 4000,
retry: false,
});
const matches: Array<Match> = data ?? [];

// We use isPaused because if there is no internet react query will be paused automatically.
if (isError || isPaused) {
return (
<div className="App">Could not load matches.</div>
);
}

return (
<ul>
{matches.map((match, index) => {
return (
<li key={index}>
<p>{match.homeTeam.name}&nbsp;-&nbsp;{match.awayTeam.name}</p>
</li>
);
})}
</ul>
);
}

export default Matches;

If we try this code, we see that it works nicely and doesn’t refresh if you navigate between the Matches and Matches2 pages. But the performance still stays around the same.

Even though this mix of React Router and React Query works in the end, I kind of dislike the way my code looks now. I feel that the original react router code was really easy to read for somebody who never used React Router before:

import React, {useState} from 'react';
import './App.css';
import {Match} from "./model/Match";
import {BrowserRouter, Link, Route, Routes} from "react-router-dom";
import Home from "./pages/Home";
import Matches from "./pages/Matches";
import Matches2 from "./pages/Matches2";

function App() {
const [matches, setMatches] = useState<Array<Match>>([]);
return (
<div className="App">
<BrowserRouter>
<Link to="/">Home</Link><br/>
<Link to="/matches">Matches</Link><br/>
<Link to="/matches2">Matches2</Link><br/>
<Routes>
<Route path="/" element={<Home/>} />
<Route path="/matches" element={<Matches matches={matches} setMatches={setMatches}/>}/>
<Route path="/matches2" element={<Matches2 matches={matches} setMatches={setMatches}/>}/>
</Routes>
</BrowserRouter>
</div>
);
}

export default App;

If a new programmer would work on my final code example he/she would need to understand:

  • What the RouterProvider and QueryClientProvider contexts do.
  • What the createBrowserRouter function does, and the concept of loaders.
  • What the <Outlet/> element does.
  • Basically my entire blog post.

I feel like that is more than just understanding the limits of the useEffect hook.

Removing the loaders

Since the react router loaders force us to refactor lots of code and didn’t improve performance, let’s ditch the loaders:

index.tsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";

const matchesQueryClient = new QueryClient();

const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<QueryClientProvider client={matchesQueryClient}>
<App/>
</QueryClientProvider>
</React.StrictMode>
);

reportWebVitals();

App.tsx:

import React from 'react';
import './App.css';
import {BrowserRouter, Link, Route, Routes} from "react-router-dom";
import Home from "./pages/Home";
import Matches from "./pages/Matches";
import Matches2 from "./pages/Matches2";

function App() {
return (
<div className="App">
<BrowserRouter>
<Link to="/">Home</Link><br/>
<Link to="/matches">Matches</Link><br/>
<Link to="/matches2">Matches2</Link><br/>
<Routes>
<Route path="/" element={<Home/>}/>
<Route path="/matches" element={
<Matches/>
}/>
<Route path="/matches2" element={
<Matches2/>
}/>
</Routes>
</BrowserRouter>
</div>
);
}

export default App;

Matches.tsx (and Matches2.tsx):

import {Match} from "../model/Match";
import React from "react";
import {useQuery} from "@tanstack/react-query";
import {matchesQuery} from "../queries/MatchesQuery";

function Matches() {
const {data, isError, isPaused} = useQuery({
...matchesQuery()
});
const matches: Array<Match> = data ?? [];

// We use isPaused because if there is no internet react query will be paused automatically.
if (isError || isPaused) {
return (
<div className="App">Could not load matches.</div>
);
}

return (
<ul>
{matches.map((match, index) => {
return (
<li key={index}>
<p>{match.homeTeam.name}&nbsp;-&nbsp;{match.awayTeam.name}</p>
</li>
);
})}
</ul>
);
}

Let’s look at the performance when you do a hard refresh (control + shift + R) on the /matches route:

I’d say good enough! You can find this version of the code here.

What else can we do?

I think most people suggest to go for server side rendering if you want to speed things up . Even though that could slow down your time to first byte.

There are a couple of things I’d recommend you to look into:

I’m probably going to look into situational server side rendering (most likely Remix or Next.js) to make my performance better. But then I need to re-evaluate my infrastructure choices, and that falls outside the scope of this blog post.

  • Thank you for reading!
  • Leave a comment if you have questions.
  • Follow me on Medium/Twitter/LinkedIn if you want to read more original programming content.

--

--

Leejjon

Java/TypeScript Developer. Interested in web/mobile/backend/database/cloud. Freelancing, only interested in job offers from employers directly. No middle men.