courses.reviews logo
I launched a free website to help you find the best courses with reviews & discounts.
Up to date
Published
12 min read

Trevor I. Lasn

Staff Software Engineer, Engineering Manager

Robust Data Fetching Architecture For Complex React/Next.js Apps

How I use the 'Three Layers of Data' architecture pattern for React and Next.js apps to avoid common pitfalls, tech debt, and improve performance

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 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?

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.

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.

React Query Devtools

Back to the three layers. The architecture is simple:

  1. Server Components - Initial data fetching
  2. React Query - Client-side caching and updates
  3. Optimistic Updates - Instant UI feedback
React Query provides two ways to optimistically update your UI before a mutation has completed. You can either use the onMutate option to update your cache directly, or leverage the returned variables to update your UI from the useMutation result.

Here’s a folder structure to help illustrate the three layers:


How Data Flows: The 3-Layer Architecture

The three layers work in sequence but remain independent:


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).

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.


Layer 2: React Query

Layer 2 consumes the initial data from Layer 1 and manages client-side state:

And here’s how a client-side component would consume from layer 2.


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.

The TeamCard component uses the useTeamMutations hook to handle team creation and deletion. It also shows loading states for each action.


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.

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.


Found this article helpful? You might enjoy my free newsletter. I share dev tips and insights to help you grow your coding skills and advance your tech career.

Interested in supporting this blog in exchange for a shoutout? Get in touch.


Liked this post?

Check out these related articles that might be useful for you. They cover similar topics and provide additional insights.

Webdev
4 min read

Understanding Vue's Suspense

How the Suspense component manages async dependencies and improves loading states in Vue apps

Aug 23, 2024
Read article
Webdev
3 min read

Preloading Responsive Images

How to properly preload responsive images to improve initial page load

Nov 28, 2024
Read article
Webdev
4 min read

Optimize Your Astro Site's <head> with astro-capo

Automatically improve your Astro site's performance using astro-capo

Oct 19, 2024
Read article
Webdev
5 min read

Add Auth to Astro 5 with Clerk in 5 Minutes

The simplest setup for adding Clerk authentication to your Astro project, with minimal code

Dec 18, 2024
Read article
Webdev
7 min read

Tips for Reducing Cyclomatic Complexity

Cyclomatic complexity is like counting how many ways a car can go. More options make it harder to drive because you have to make more decisions, which can lead to confusion.

Sep 10, 2024
Read article
Webdev
3 min read

The HTML Native Search Element

The search HTML element is a container that represents the parts of the web page with search functionality

Dec 2, 2024
Read article
Webdev
13 min read

10 Essential Terminal Commands Every Developer Should Know

List of useful Unix terminal commands to boost your productivity. Here are some of my favorites.

Aug 21, 2024
Read article
Webdev
8 min read

Stop Using localStorage for Sensitive Data: Here's Why and What to Use Instead

Understanding the security risks of localStorage and what to use instead for tokens, secrets, and sensitive user data

Oct 28, 2024
Read article
Webdev
4 min read

Open Dyslexic Font: Improve Your Web Accessibility

How to implement the Open-Dyslexic font to enhance readability for users with dyslexia

Oct 12, 2024
Read article

This article was originally published on https://www.trevorlasn.com/blog/fetching-data-for-complex-next-and-react-apps. It was written by a human and polished using grammar tools for clarity.