Vanta Logo
SPONSOR
Automate SOC 2 & ISO 27001 compliance with Vanta. Get $1,000 off.
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.

If you found this article helpful, you might enjoy my free newsletter. I share developer tips and insights to help you grow your skills and career.


More Articles You Might Enjoy

If you enjoyed this article, you might find these related pieces interesting as well. If you like what I have to say, please check out the sponsors who are supporting me. Much appreciated!

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

Form Validation That Doesn't Annoy Users: CSS :user-valid and :user-invalid

The new pseudo-classes :user-valid and :user-invalid give us a smarter way to style form validation states based on user interaction

Dec 12, 2024
Read article
Webdev
4 min read

Remove Unnecessary NPM Packages with eslint-plugin-depend

We don't need packages to handle basic JavaScript tasks

Aug 13, 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
4 min read

Self-Taught Developer's Guide to Thriving in Tech

How to turn your non-traditional background into your biggest asset

Sep 28, 2024
Read article
Webdev
3 min read

CSS content-visibility: The Web Performance Boost You Might Be Missing

The content-visibility CSS property delays rendering an element, including layout and painting, until it is needed

Dec 5, 2024
Read article
Webdev
8 min read

Invisible columns in SQL

It’s a small feature, but it can make a big difference.

Aug 26, 2024
Read article
Webdev
4 min read

How To Implement Content Security Policy (CSP) Headers For Astro

Content Security Policy (CSP) acts like a shield against XSS attacks. These attacks are sneaky - they trick your browser into running malicious code by hiding it in content that seems trustworthy. CSP's job is to spot these tricks and shut them down, while also alerting you to any attempts it detects.

Oct 16, 2024
Read article
Webdev
4 min read

Explicit is better than implicit

Clarity is key: being explicit makes your code more readable and maintainable.

Sep 4, 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.