Most of us underestimate data fetching complexity until it’s too late. Many projects begin innocently with useEffect()
and fetch()
sprinkled across components.
Before you know it, the growing tangle of error handlers, loading states, memoization, and caching logic turns your code into a debugging nightmare.
Here are common issues I see in many apps:
[1] Components firing duplicate network requests because someone forgot to cache the result
[2] Components re-rendering dozens of times per second from poorly managed state
[3] Too many skeleton loaders, making the app feel perpetually slow
[4] Users seeing stale data for seconds after mutations because cache invalidation is broken
[5] Race conditions when parallel queries return in unexpected order
[6] Memory leaks from uncleared subscriptions and event listeners that never get cleaned up
[7] Optimistic updates that fail silently, leaving data inconsistent
[8] Server-side data that goes stale immediately on navigation
[9] Polling for updates that never happen, or worse, poll every second when the data is static
[10] Components that are too tightly coupled to their data fetching logic, making them hard to reuse
[11] Sequential network requests where each query depends on the previous result, e.g get user → get user’s org → get org’s teams → get team members
…and so on.
These issues compound. A single badly architected data fetching pattern spawns three more problems. Before you know it, a “simple” dashboard needs refactoring from the ground up.
This article shows you a better way, or atleast how I prefer to structure my apps. We’ll build three layers of data fetching that scale from simple CRUD to complex, real-time applications without breaking your mental model.
But before we visit the three layer data approach; Your first instinct might be to toss useEffect()
and fetch()
into a component and move on.
Here’s why this approach derails fast:
// The Growing Monster Component ❌export function TeamDashboard() { const [user, setUser] = useState(null); const [org, setOrg] = useState(null); const [teams, setTeams] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isCreating, setIsCreating] = useState(false); const [lastUpdated, setLastUpdated] = useState(null);
// Waterfall ❌ useEffect(() => { const fetchData = async () => { try { // User request const userData = await fetch('/api/user').then(res => res.json()); setUser(userData);
// Wait for user, then fetch org const orgData = await fetch(`/api/org/${userData.orgId}`).then(res => res.json()); setOrg(orgData);
// Wait for org, then fetch teams const teamsData = await fetch(`/api/teams?orgId=${orgData.id}`).then(res => res.json()); setTeams(teamsData); setIsLoading(false); } catch (err) { setError(err.message); setIsLoading(false); } };
fetchData(); }, []);
// Handle window focus to refetch useEffect(() => { const handleFocus = async () => { if (!user?.id) return; setIsLoading(true); await refetchData(); };
window.addEventListener('focus', handleFocus); return () => window.removeEventListener('focus', handleFocus); }, [user?.id]);
// Polling for updates useEffect(() => { if (!user?.id || !org?.id) return;
const pollTeams = async () => { try { const teamsData = await fetch(`/api/teams?orgId=${org.id}`).then(res => res.json()); setTeams(teamsData); } catch (err) { // Silent fail or show error? console.error('Polling failed:', err); } };
const interval = setInterval(pollTeams, 30000); return () => clearInterval(interval); }, [user?.id, org?.id]);
const refetchData = async () => { try { const userData = await fetch('/api/user').then(res => res.json()); const orgData = await fetch(`/api/org/${userData.orgId}`).then(res => res.json()); const teamsData = await fetch(`/api/teams?orgId=${orgData.id}`).then(res => res.json());
setUser(userData); setOrg(orgData); setTeams(teamsData); setLastUpdated(new Date()); } catch (err) { setError(err.message); } finally { setIsLoading(false); } };
const createTeam = async (newTeam) => { setIsCreating(true); try { const response = await fetch('/api/teams', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newTeam), });
if (!response.ok) throw new Error('Failed to create team');
const createdTeam = await response.json();
// Optimistic update attempt setTeams(prev => [...prev, createdTeam]);
// Or full refetch because you're paranoid await refetchData(); } catch (err) { setError(err.message); // Need to rollback optimistic update? // But which teams were the original ones? } finally { setIsCreating(false); } };
// Component unmount cleanup useEffect(() => { return () => { // Cancel any pending requests? // How do we track them all? }; }, []);
// The render logic is still complex if (isLoading && !teams.length) { return <LoadingSpinner />; }
if (error) { return <ErrorDisplay message={error} onRetry={refetchData} />; }
return ( <div> <h1>{org?.name}'s Dashboard</h1> {isLoading && <div>Refreshing...</div>} <TeamList teams={teams} onCreate={createTeam} isCreating={isCreating} /> {lastUpdated && <div>Last updated: {lastUpdated.toLocaleTimeString()}</div>} </div> );}
The many problems with this approach:
- Waterfall: Requests execute sequentially (more on this later)
- State management chaos: Multiple
useState
hooks that can get out of sync - Memory leaks: Event listeners, intervals need manual cleanup
- No request cancellation: How do you cancel in-flight requests on unmount?
- Complex loading states: When is isLoading true/false? Which loading?
- Error boundaries: Where do errors bubble up to?
- Cache staleness: No way to mark data as stale
- Optimistic update nightmare: Manual rollback logic needed
- Dependency arrays: Easy to miss dependencies and cause bugs
- Testing complexity: Mocking all these effects is a nightmare
This mess grows exponentially as your app scales. Every new feature adds more state, more effects, and more edge cases to handle.
Sure, you can use libraries like Redux or MobX to manage state, but they add complexity and boilerplate. You end up with a tangled web of actions, reducers, and selectors that are hard to follow. (I like both libraries, but they are not the solution to this problem)
You might be thinking, “But I can just use useReducer()
and useContext()
to manage this!” Sure, but that doesn’t solve the underlying problem of data fetching complexity. You still have to deal with the same issues of loading states, error handling, and cache invalidation.
On a another note, you might be thinking why cant’t I just fetch all the data in one go? Why not just do this?
export default function Dashboard() {
// Creates a waterfall - each request waits for the previous ❌ const { user } = useUser(); // Request 1 const { org } = useOrganization(user?.id); // Request 2 (waits) const { teams } = useTeams(org?.id); // Request 3 (waits more)
// Total delay: 600-1200ms return <DashboardView user={user} org={org} teams={teams} />;}
Server components are faster and more efficient way of doing this. They allow you to fetch data on the server and send it to the client in one go, reducing the number of network requests and improving performance.
export default async function Dashboard() {
const user = await getUser();
// fetch org and teams in parallel using user data ✅ const [org, teams] = await Promise.all([ getOrganization(user.orgId), getTeamsByOrgId(user.orgId) ]);
return <DashboardView user={user} org={org} teams={teams} />;}
What if I told you there was a better way? A way to structure your data fetching that scales with your app and keeps your components clean and focused.
This is where the Three Layers of Data Architecture come in. This pattern separates your data fetching into three distinct layers, each with its own responsibilities. This makes your app easier to reason about, test, and maintain.
Three Layers of Data Architecture
The solution is to build a three-layer architecture that separates concerns and makes your app easier to reason about. This architecture is inspired by the principles of React Query, which provides a powerful way to manage server state in your application.
You don’t have to use React Query, but it’s my go-to library for data fetching and caching. It handles a lot of the boilerplate for you, so you can focus on building your app.
Note: If you decide to use React Query, don’t forget to use <ReactQueryDevtools />
in development. It makes debugging a breeze.
Back to the three layers. The architecture is simple:
- Server Components - Initial data fetching
- React Query - Client-side caching and updates
- Optimistic Updates - Instant UI feedback
React Query provides two ways to optimistically update your UI before a mutation has completed. You can either use theonMutate
option to update your cache directly, or leverage the returned variables to update your UI from theuseMutation
result.
Here’s a folder structure to help illustrate the three layers:
app/├── page.tsx # Layer 1: Server Component entry├── api/│ └── teams/│ └── route.ts # GET, POST teams│ └── [teamId]/│ └── route.ts # GET, PUT, DELETE specific team├── TeamList.tsx # Client component consuming Layers 2 & 3├── components/ # Fix: Add this folder│ └── TeamCard.tsx└── ui/ ├── error-state.tsx # Layer 2: Error handling states └── loading-state.tsx # Layer 2: Loading states
hooks/├── teams/│ ├── useTeamsData.ts # Layer 2: React Query hooks│ └── useTeamMutations.ts # Layer 3: Mutations with optimism
queries/ # Layer 1: Server-side database queries├── teams/│ ├── getAllTeams .ts│ ├── getTeamById.ts│ ├── getTeamsByOrgId.ts│ ├── deleteTeamById.ts│ ├── createTeam.ts│ ├── updateTeamById.ts
context/└── OrganizationContext.tsx # Layer 2: Centralized data management
How Data Flows: The 3-Layer Architecture
The three layers work in sequence but remain independent:
User Request ↓Layer 1: Server Component• getAllTeams() → Database• Returns HTML with data ↓Layer 2: React Query• Hydrates with server data• Manages client-side cache• Handles refetching ↓Layer 3: User Actions• Optimistic updates• Real mutations• Cache invalidation
Layer 1: Server Components
Server Components handle initial data fetching, making your app feel instant. But they don’t update dynamically - that’s where React Query enters (Layer 2).
import { getAllTeams } from '@/queries/teams/getAllTeams';import { TeamList } from './TeamList';import { OrganizationProvider } from '@/context/OrganizationContext';
export default async function Page() { // Layer 1: Fetch initial data on server const teams = await getAllTeams();
return ( <main> <h1>Teams Dashboard</h1> {/* Pass server data to React Query via context */} <OrganizationProvider initialTeams={teams}> <TeamList /> </OrganizationProvider> </main> );}
The getAllTeams
function is a simple database query that fetches all teams. It can be a simple SQL query or an ORM call, depending on your setup.
import { db } from '@/lib/db'; // Database or ORM connectionimport { Team } from '@/types/team';import { NextResponse } from 'next/server';
export async function getAllTeams(): Promise<Team[]> { try { const teams = await db.team.findMany(); return teams; } catch (error) { throw new Error('Failed to fetch teams'); }}
Layer 2: React Query
Layer 2 consumes the initial data from Layer 1 and manages client-side state:
import { useQuery } from '@tanstack/react-query';
export function useTeamsData(initialData: Team[]) { return useQuery({ queryKey: ['teams'], queryFn: async () => { // Client-side must use API routes, not direct queries // I want to keep my server and client code separate const response = await fetch('/api/teams'); if (!response.ok) throw new Error('Failed to fetch teams'); return response.json(); }, initialData, // Received from Server Component via context staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, });}
And here’s how a client-side component would consume from layer 2.
'use client'
import { useOrganization } from '@/context/OrganizationContext';import { LoadingState } from '@/ui/loading-state';import { ErrorState } from '@/ui/error-state';
export function TeamList() { // Data from Layer 2 context const { teams, isLoadingTeams, error } = useOrganization();
if (error) { return <ErrorState message="Failed to load teams" />; }
if (isLoadingTeams) { return <LoadingState />; }
return ( <div> {teams.map(team => ( <TeamCard key={team.id} team={team} /> ))} </div> );}
Layer 3: Optimistic Updates
Layer 3 is where the magic happens. It allows you to update the UI instantly while the server processes the request. This is done using optimistic updates.
Mutations are handled in a separate hook, useTeamMutations
, which uses React Query’s useMutation
to handle the creation and deletion of teams.
// Layer 3: Mutations with optimismimport { useMutation, useQueryClient } from '@tanstack/react-query';
export function useTeamMutations() { const queryClient = useQueryClient();
const createTeamMutation = useMutation({ mutationFn: async (newTeam: { name: string; members: string[] }) => { const response = await fetch('/api/teams', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newTeam), }); if (!response.ok) throw new Error('Failed to create team'); return response.json(); }, onMutate: async (newTeam) => { await queryClient.cancelQueries({ queryKey: ['teams'] }); const currentTeams = queryClient.getQueryData(['teams']); queryClient.setQueryData(['teams'], old => [ ...old, { ...newTeam, id: `temp-${Date.now()}` } ]); return { currentTeams }; }, onError: (err, variables, context) => { queryClient.setQueryData(['teams'], context.currentTeams); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['teams'] }); } });
const deleteTeamMutation = useMutation({ mutationFn: async (teamId: string) => { const response = await fetch(`/api/teams/${teamId}`, { method: 'DELETE', }); if (!response.ok) throw new Error('Failed to delete team'); return response.json(); }, onMutate: async (teamId) => { await queryClient.cancelQueries({ queryKey: ['teams'] }); const currentTeams = queryClient.getQueryData(['teams']); queryClient.setQueryData(['teams'], old => old.filter(team => team.id !== teamId) ); return { currentTeams }; }, onError: (err, teamId, context) => { queryClient.setQueryData(['teams'], context.currentTeams); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['teams'] }); } });
return { createTeam: createTeamMutation.mutate, deleteTeam: deleteTeamMutation.mutate, isCreating: createTeamMutation.isLoading, isDeleting: deleteTeamMutation.isLoading, };}
The TeamCard
component uses the useTeamMutations
hook to handle team creation and deletion. It also shows loading states for each action.
'use client'// TeamList.tsx - Using Layer 3 mutationsimport { useTeamMutations } from '@/hooks/teams/useTeamMutations';
interface TeamCardProps { team: { id: string; name: string; members: string[]; };}
export function TeamCard({ team }: TeamCardProps) { const { deleteTeam, isDeleting } = useTeamMutations();
return ( <div className="p-4 border border-gray-200 rounded-lg mb-4"> <h3 className="text-lg font-semibold">{team.name}</h3> <p className="text-gray-600">Members: {team.members.length}</p> <button onClick={() => deleteTeam(team.id)} disabled={isDeleting} className="mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50" > {isDeleting ? 'Deleting...' : 'Delete Team'} </button> </div> );}
Context: Tying It All Together
Context providers eliminate prop drilling and centralize data access. This is especially useful for complex apps with multiple components needing the same data.
import { createContext, useContext } from 'react';import { useTeamsData } from '@/hooks/teams/useTeamsData';
interface OrganizationContextValue { teams: Team[]; isLoadingTeams: boolean; error: Error | null;}
const OrganizationContext = createContext<OrganizationContextValue | null>(null);
export function OrganizationProvider({ children, initialTeams }) { const { data: teams, isLoading, error } = useTeamsData(initialTeams);
return ( <OrganizationContext.Provider value={{ teams, isLoadingTeams: isLoading, error }}> {children} </OrganizationContext.Provider> );}
export function useOrganization() { const context = useContext(OrganizationContext); if (!context) { throw new Error('useOrganization must be used within OrganizationProvider'); } return context;}
The OrganizationProvider
wraps the TeamList
component, providing it with the initial data from Layer 1 and managing the loading state and errors.
For a more complex app, you can add more context providers for different data layers. For example, you might have a UserContext
for user data and an AuthContext
for authentication state.
All this allows you to keep your components clean and focused on rendering, while the data fetching and state management is handled in a centralized way.
The three layer data approach is over kill for simple apps, but it scales well for larger applications. It also makes testing easier, as you can mock the context providers and test components in isolation.
P.S: You can use a similar approach with Vue.js, Svelte, or any other framework. The key is to separate concerns and keep your components focused on rendering.