DEV Community

WingsDevelopment
WingsDevelopment

Posted on

The Architecture Mistakes That Slowly Kills Large React Projects

Problems:

  • Too much data on the client
  • Overuse of useMemo
  • React Query becomes too complex for on-chain fetching
  • Unpredictable re-renders
  • Hard-to-maintain hook logic
  • Abstractions created too early that can’t evolve with the project

Introduction

Modern DeFi front-ends try to provide maximum transparency:

everything fetched on-chain, everything computed live, everything reactive.

This works beautifully at the beginning.

You have a clean UI, a few contract calls, a couple of hooks, and everything feels manageable.

But as the project grows, so does the data:

  • more vaults
  • more markets
  • more balances
  • more derived state
  • more APYs
  • more user positions

Suddenly your front-end is doing the job of a backend + database, but inside React components.

Small mistakes from the early days, a helper hook here, an abstraction there — start to pile up.

More conditions, more memos, more state.

Before you notice, you're fighting complexity you never intended to create.

This post explains why this happens, why React Query becomes extremely tricky in web3, and how a simple architecture, fetch → mapper → hook — solves these problems permanently.


Too Much Data on the Client

As our datasets grew, we realized not everything needs to be fetched live from the chain.

Some data is expensive to compute, rarely changes, or is aggregated from many sources.

So we shifted specific classes of data to a backend (or subgraph/task worker), then consumed it with the Fetch → Mapper → UI pattern.

But Isn’t This Pattern Less Useful If You Already Have a Backend?

You might argue that the Fetch → Mapper → UI architecture becomes less necessary once you introduce a backend.

But here’s the reality:

Even with a strong backend, you will always have data that must come directly from the chain.

These values are too time-sensitive, user-specific, or transaction-gating to safely offload to a backend.

This creates an unavoidable challenge:

Your front-end must merge two different worlds:

  1. Backend-provided data (cached, aggregated, slow-changing)
  2. On-chain data (live, reactive, per-user)

The Fetch → Mapper → UI pattern is exactly what makes this possible.

  • The fetchers isolate how and where the data comes from (BE or chain).
  • The mappers combine, normalize, format, and reconcile those two sources.
  • The UI receives a single, clean, stable data object — without knowing (or caring) whether it came from RPC, BE, or both.

Overusing useMemo

In a traditional app, the backend prepares data for you.

In web3, the blockchain gives you raw, primitive state, you must compute everything yourself.

More data → more derived calculations → more useMemo.

While having only 10 vaults, even if you have 20 memos per vault, its not a big issue, but once number of vaults grow..

  • 10 vaults → 200 memos
  • 500 vaults → 10k memos!

Every re-render triggers:

  • dependency comparisons
  • recalculations
  • diffing
  • memory usage
  • race conditions

As data grows, the number of “safe mistakes” drops to zero.

A single unstable dependency crashes performance.

React query is too hard in web3 for on-chain fetching

I have reviewed a lot of projects in DeFi and many of us tried to use useQuery like in web2 application, making a huge mistake.

You might wonder why does it matter for react-query if I am building web2 or web3 application, i am just making RPC calls instead of a HTTP right? Well its not that simple.

In web2 you have database, or BE service, that is calculating, formatting, joining the data and you get 1 hook - 1 query - everything you need.

While in web3 you have only chain, that is much more primitive, and you have to do what would DB do for you in FE, so you end up having situation that looks more like this:

And you might think, its not a big deal,

You start by thinking, “I’ll just add an if in the enabled field so it fetches only when needed.”

Then you add some custom refetch logic to the queryKeys.

Then you calculate combined loading and error states across multiple useQuery hooks.

Maybe you even sprinkle in a little caching or a manual refetch call “just to handle an edge case.”

And before you realize it, you’ve built hooks on top of hooks, hundreds of lines deep — full of branching conditions, complex dependency rules, and no straightforward if, else, or loops. It becomes hard to follow, hard to debug, and even harder to maintain.

Take a look at simple but still confusing hook-heavy approach:

 // 1: Fetch vault
const {
  data: vault,
  isLoading: isVaultLoading,
} = useVault({
  address: vaultAddress,
  query: { refetchOnMount: false },
})

// 2: Fetch Net Asset Value (depends on vault)
const {
  data: netAssetValue,
  isLoading: isNetAssetValueLoading,
} = useNetAssetValue({
  accountAddress: account,
  vaultAddress,
  enabled: Boolean(vault),                 // enabled #1
})

// 3: Fetch APY (also depends on vault)
const {
  data: apy,
  isLoading: isApyLoading,
} = useApy({
  account,
  vaultAddress,
  enabled: Boolean(vault),                 // enabled #2
})

// Global loading state becomes:
const isLoading =
  isVaultLoading ||
  isNetAssetValueLoading ||
  isApyLoading

// todo errors.. and other things..
Enter fullscreen mode Exit fullscreen mode

This also means that for every data point, we now have not only the value itself but an entire bundle of React Query state attached to it - loading, error, status flags, fetch timestamps, and more.

And because most of these hooks depend on each other, the entire composite hook becomes “loading” if any of the underlying queries are loading. In practice, you need all the data anyway, so the multiple useQuery calls end up serving only one purpose: caching.

Which leads to an obvious question:

Why not simply fetch everything together instead?

// example mapper 
// 1. Fetch the vault (required) and await for it!
const vault = await fetchVault(vaultAddress)

// clear failure path, no helper flags in React
if (!vault) throw new Error("Vault not found")

// 2. Fetch dependent values in parallel
const [netAssetValue, apy] = await Promise.all([
  fetchNetAssetValue({ accountAddress: account, vaultAddress, }),
  fetchApy({ account, vaultAddress }),
  // easily add more in a single line!
])

// 3. Return fully composed result
return { vault, netAssetValue, apy }
Enter fullscreen mode Exit fullscreen mode

Much easier to read!

After fetching everything in mapper, you simply wrap the entire operation in a single useQuery. This gives you reactivity, loading and pending states, error handling, and all the other benefits of React Query - without scattering logic across multiple hooks.

Much easier to read, maintain, and reason about, right? In my opinion: absolutely yes.

But the next question is that you might be thinking of is:

“What about caching?”

This is where things get interesting.

React Query also provides a non-hook API through the queryClient.

When I discovered queryClient.fetchQuery for the first time, it completely changed the way I approached data fetching. You can access the same query client you initialized at the root of your project and call .fetchQuery() anywhere - with full support for staleTime, refetchOnWindowFocus, retries, and all other React Query options.

Here’s a simple example of a fetch function that retrieves the owner of a vault and caches it for 30 minutes:

export const getOwnerQueryOptions = (
  vaultAddress: Address,
  chainId: number,
) => ({
    // readContractQueryOptions utils from wagmi
  ...readContractQueryOptions(getWagmiConfig(), {
    abi: eulerEarnAbi,
    address: vaultAddress,
    chainId,
    functionName: "owner",
    args: [],
  }),
    staleTime: 30 * 60 * 1000, // <- cache smallest part of the data
});

export async function fetchOwner(vaultAddress: Address, chainId: number) {
  const result = await getQueryClient().fetchQuery( // <- fetch query from query client
    getOwnerQueryOptions(vaultAddress, chainId),
  );

  return result;
}

Enter fullscreen mode Exit fullscreen mode

This means that whenever you use this fetch function inside a higher-level operation, the call is already cached - no need to define caching logic anywhere else.

With this pattern in place, the large, complicated hook I showed earlier has no reason to call hooks inside hooks anymore. It can simply call plain fetch functions.

Now imagine the requirements change.

For example, before fetching anything, you now need to check whether a vault is verified and throw an error if it isn’t.

In the old hook-based approach, you would need to update every internal hook, add enabled conditions, and touch ~10 different places.

With plain fetch functions, the change becomes trivial - you just write something like:

const isVerified = await fetchIsVerified(debt?.vault);
if (!isVerified) throw new Error(Vault is not verified); // <- you can use if! 
// … rest of the code here…
Enter fullscreen mode Exit fullscreen mode

You may also start running into issues when calculating the final state.

Maybe you weren’t handling certain error cases before and now you need to.

Maybe you need to understand exactly when individual queries are refetching.

With the hook-heavy approach, this becomes complicated quickly, and a lot of unexpected behaviors can occur.

Unpredictable re-renders

This problem naturally emerges from the previous issues.

When your data layer becomes too reactive and too tightly coupled to multiple hooks, the UI starts re-rendering in ways that feel random or impossible to control.

The first instinct is always the same:

“Let’s just wrap it in useMemo.”

But useMemo doesn’t eliminate complexity — it adds more:

  • another dependency array
  • another equality check
  • more memory
  • another place things can go wrong

Instead of stabilizing your UI, you now have yet another reactive layer to manage.

Let’s do some math.

Imagine a hook that internally uses:

  • 7 different useQuery calls → 7 query keys
  • several formatting or sorting operations wrapped in useMemo3–5 more dependency arrays

You’re now maintaining 10+ separate dependency arrays, all of which React must compare on every render, for every feature that uses this hook.

As the app scales:

  • the number of dependency arrays increases
  • the number of query keys explodes
  • one unstable reference triggers a cascade
  • the UI becomes harder to predict
  • small mistakes cause large re-render storms

Even if every dependency is “correct,” React still needs to store all these arrays in memory and compare them constantly. Multiply this across dozens of components and the cost becomes significant.

This is why “just add more useMemo” is not a performance strategy — it’s a sign the architecture needs to be simplified.

Fetch - mapper - hook layers for getting the data

Moving away from the way we fetched data helped us get rid of all of this things as well, we introduced 3 layers when fetching data,

  • fetch functions (which we already covered in fetchOwner example,
  • mappers layer before useQuery that calls as many fetch function
  • and at the end useQuery to wrap this mapper and get reactivity for UI

I already partially covered why do we need this, but if you are interested to see diagram and more details about this approach maybe you can check out THIS BLOG.

Creating abstractions in early stage that can’t support project evolution

This is a mistake nearly every team makes at some point, myself included. In the early stages of a project, you often don’t fully understand the real data flows, the real scale, or how features will evolve.

You create abstractions with the best intentions: to be clean, reusable, “future-proof.” But the truth is that early abstractions usually aren’t future-proof at all. They’re built on assumptions that later turn out to be wrong.

Final thoughts

React Query is an amazing library, but web3 is fundamentally different from web2. You can’t treat on-chain RPC calls the same way you treat HTTP endpoints. When you try to, you end up with:

  • Massive nested hooks
  • Too many queries
  • Broken loading/error states
  • useMemo everywhere
  • Unpredictable renders
  • Complex dependency arrays
  • Hooks that grow to 500–800 lines before anyone notices

By moving fetching out of hooks, simplifying the pipeline, and letting Promise.all do the heavy lifting—while still using React Query for reactivity—you get a far cleaner architecture:

  • Debuggable
  • Predictable
  • Performant
  • Scalable
  • Easy to maintain

And most importantly:

Your front-end stops fighting the blockchain - and starts working with it.


Top comments (0)