Improving Data Fetching in Next.js: Lessons from Moving Beyond useEffect
Source: Dev.to
Introduction
At Subito (Italy’s leading classifieds platform), we never paid much attention to how we made API calls in our web‑frontend microsites, all built on Next.js. We used a simple library based on Axios that handled requests, data modeling, and error management. On the server side it was a straightforward call; on the client side we relied on useEffect with state management for data and errors. It worked—until we started asking whether this was still the right approach.
The turning point was reading about the Cloudflare outage caused by excessive API calls triggered by useEffect. It made us reflect on our own stack:
- Are we over‑fetching?
- Are we accidentally triggering duplicate requests?
- Could we overload our backend without realizing it?
Our primary goal became clear: embrace the standard and align with the official recommendations from React and Next.js.
Server‑Side Data Fetching
Since our architecture relies on the Next.js App Router to consume REST APIs, we needed a clean, well‑defined strategy that distinguishes between React Server Components and Client Components.
Native fetch with Next.js
Next.js extends the native fetch API, adding powerful caching and revalidation features out of the box. We replaced all Axios calls in our Server Components with this native fetch implementation.
Benefits:
- Removed the ~13 KB Axios bundle from the client.
- Eliminated multiple historical security advisories tied to Axios.
- Gained built‑in caching and revalidation without extra configuration.
Client‑Side Data Fetching
On the client side we compared two popular libraries:
| Library | Pros | Cons for our use case |
|---|---|---|
| TanStack Query (React Query) | Massive ecosystem, advanced cache control, optimistic updates | Requires a QueryClient, a provider, and careful queryKey management—overkill for our simple needs. |
| SWR (Stale‑While‑Revalidate) | Zero boilerplate, no mandatory providers, natural extension of the platform, client‑only by design | Fits perfectly with our already‑solved server‑side fetching. |
Why SWR Won
- Zero Boilerplate – just a hook:
useSWR(key, fetcher). - Low Learning Curve – feels like a natural extension of Next.js.
- Client‑Only Design – aligns with our separation of concerns.
Migrating from useEffect to SWR
Before (using useEffect)
import { useEffect, useState } from 'react';
const [items, setItems] = useState>([]);
const [isLoading, setIsLoading] = useState(true);
// load the Ads
useEffect(() => {
getRecommendedItems(vertical)
.then(setItems)
.finally(() => setIsLoading(false));
}, [vertical]);After (using SWR)
import useSWR from 'swr';
const { data: items = [], isLoading } = useSWR(
SWR_KEYS.recommender(vertical),
fetchRecommendedItems
);Result: Removed two useState declarations and replaced a bulky useEffect block with a single, declarative hook.
Centralizing SWR Keys
export const SWR_KEYS = {
recommender: (vertical: string) => ['recommender', 'items', vertical] as const,
user: (userId: string) => ['user', userId] as const,
};- Type‑safe and predictable.
- Avoids “magic strings” and ensures consistent caching.
Testing with SWR
Because SWR caches globally, we created a utility to isolate unit tests:
import { render } from '@testing-library/react';
import { SWRConfig } from 'swr';
export const renderWithSWR = (ui: React.ReactElement) => {
return render(
<SWRConfig value={{ provider: () => new Map() }}>
{ui}
</SWRConfig>
);
};
// Usage example
// renderWithSWR(<MyComponent />);Takeaways
- Simplicity wins: For our scenario—few client‑side mutations, no shared cache between server and client, and reliance on backend caching—SWR + native
fetchprovided the cleanest solution. - Avoid over‑engineering: React Query is excellent for complex cache invalidation, optimistic updates, or tight server‑client state synchronization. It wasn’t needed for us.
- Understand requirements first: Choose the tool that fits the problem, not the hype.
By moving away from manual useEffect fetching and adopting the SWR + Native Fetch combo, we achieved cleaner code and a more React‑standard way to handle data fetching. Sometimes the simplest tool is the most effective one.