The Architecture Mistakes That Slowly Kills Large React Projects

Published: (December 27, 2025 at 05:38 AM EST)
8 min read
Source: Dev.to

Source: Dev.to

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
  • 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:

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

SourceCharacteristics
Backend‑provided datacached, aggregated, slow‑changing
On‑chain datalive, reactive, per‑user

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

  1. Fetchers isolate how and where the data comes from (backend or chain).
  2. Mappers combine, normalize, format, and reconcile those two sources.
  3. UI receives a single, clean, stable data object—without knowing (or caring) whether it came from RPC, backend, or both).

Overusing useMemo

In a traditional app, the backend prepares data for you.
In Web3, the blockchain gives you raw, primitive state, and you must compute everything yourself.

  • More data → more derived calculations → more useMemo.
  • With only 10 vaults, even 20 memos per vault isn’t a big issue, but once the number of vaults grows…
# of vaults# of memos (≈20 × vaults)
10200
50010 000

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 can crash performance.

React Query in Web3 vs. Web2

I have reviewed many DeFi projects and noticed a common mistake: using useQuery the same way as in Web2.

“Why does it matter for React Query if I’m building a Web2 or Web3 app? I’m just making RPC calls instead of HTTP, right?”

It’s not that simple.

Web2Web3
Database / BE service does the heavy lifting (calculations, joins, formatting).Only the chain is available → you must do all DB‑like work in the FE.
One hook → one query → everything you need.Multiple primitive calls → you end up with many inter‑dependent hooks.

What the typical (messy) hook‑heavy approach looks

// 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
const isLoading =
  isVaultLoading ||
  isNetAssetValueLoading ||
  isApyLoading;

// TODO: handle errors, etc.

Problems with this pattern

  • Each data point carries a bundle of React Query state (loading, error, status flags, timestamps, etc.).
  • Because most hooks depend on each other, the composite hook becomes “loading” if any underlying query is loading.
  • In practice you need all the data anyway, so the multiple useQuery calls end up serving only one purpose: caching.

A natural question arises:

Why not simply fetch everything together instead?

A Simpler Approach: Fetch → Mapper → Hook

Below is a sketch of how you can replace the tangled hook‑heavy code with a single, clear data‑flow.

// mapper.ts
export async function fetchVaultData({
  vaultAddress,
  account,
}: {
  vaultAddress: string;
  account: string;
}) {
  // 1️⃣ Fetch the vault (required) and await it
  const vault = await fetchVault(vaultAddress);
  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 }),
  ]);

  // 3️⃣ Combine / map everything into a single object
  return {
    vault,
    netAssetValue,
    apy,
  };
}
// useVaultData.ts
import { useQuery } from '@tanstack/react-query';
import { fetchVaultData } from './mapper';

export function useVaultData({
  vaultAddress,
  account,
}: {
  vaultAddress: string;
  account: string;
}) {
  return useQuery(
    ['vaultData', vaultAddress, account],
    () => fetchVaultData({ vaultAddress, account }),
    {
      // you can still control refetching, caching, etc.
      staleTime: 60_000,
      enabled: Boolean(vaultAddress && account),
    }
  );
}

Benefits

  1. Single source of truth – one query returns a fully‑formed data object.
  2. No cascade of loading flags – the hook’s isLoading reflects the whole payload.
  3. Easier to test & maintain – the mapper is a pure function that can be unit‑tested.
  4. Reduced useMemo usage – derived values are computed once in the mapper, not on every render.

TL;DR

  • Don’t let the front‑end become a makeshift backend. Move static/expensive data to a real backend or subgraph.
  • Avoid proliferating useMemo. Compute derived data in a dedicated mapper instead of scattering memo hooks across components.
  • Replace tangled webs of useQuery hooks with a single “fetch → mapper → hook” pipeline. This gives you a clean, predictable data flow that scales as your DeFi app grows.

Refactoring Data Fetching with React Query

// 1. Define the query options
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 the smallest part of the data (30 min)
});

// 2. Fetch the data using the query client
export async function fetchOwner(vaultAddress: Address, chainId: number) {
  const result = await getQueryClient().fetchQuery(
    // ← fetch query from query client
    getOwnerQueryOptions(vaultAddress, chainId),
  );

  return result;
}

Why This Is Simpler

  • Single source of truth – All fetching logic lives in plain functions.
  • Built‑in cachingstaleTime handles expiration automatically.
  • No hook‑inside‑hook – The higher‑level operation can call fetchOwner directly, keeping the hook thin.

From Hook‑Heavy to Plain Fetch Functions

Previously we might have written a massive hook that looked like this (simplified):

// Example of a large, hook‑heavy implementation
function useVaultData(vaultAddress: Address, chainId: number) {
  const { data: owner } = useQuery(getOwnerQueryOptions(vaultAddress, chainId));
  const { data: isVerified } = useQuery(getVerifiedQueryOptions(vaultAddress));
  // … many more useQuery calls, useMemo, etc.
  // 500–800 lines of intertwined logic
}

Problems with that approach

  1. Scattered caching logic – Every internal hook needs its own enabled, staleTime, etc.
  2. Hard to change requirements – Adding a pre‑check (e.g., “is the vault verified?”) forces updates in dozens of places.
  3. Performance overhead – Many useMemo calls, dependency arrays, and query keys cause unnecessary re‑renders.

A Cleaner Flow

  1. Fetch functions – Pure async functions that return data (e.g., fetchOwner, fetchIsVerified).
  2. Mapper layer – A function that calls as many fetch functions as needed (often with Promise.all).
  3. useQuery wrapper – A single useQuery that wraps the mapper, providing UI reactivity.
// 1️⃣ Fetch functions (already shown above)

// 2️⃣ Mapper that composes the data
async function fetchVaultInfo(vaultAddress: Address, chainId: number) {
  const [owner, isVerified] = await Promise.all([
    fetchOwner(vaultAddress, chainId),
    fetchIsVerified(vaultAddress, chainId),
  ]);

  if (!isVerified) {
    throw new Error("Vault is not verified");
  }

  return { owner, isVerified };
}

// 3️⃣ Hook that gives the UI reactivity
export function useVaultInfo(vaultAddress: Address, chainId: number) {
  return useQuery({
    queryKey: ["vaultInfo", vaultAddress, chainId],
    queryFn: () => fetchVaultInfo(vaultAddress, chainId),
    staleTime: 5 * 60 * 1000, // example cache duration
  });
}

Now the hook is tiny, the data‑layer is testable, and caching is handled once in the fetch functions.

The Cost of Over‑Reactive Architecture

IssueWhat Happens
Many useQuery calls7+ query keys → many independent caches
Multiple useMemo wrappers3‑5 extra dependency arrays per hook
10+ dependency arraysReact must compare them on every render
ScalingMore components → more arrays & keys → cascade of re‑renders
Memory & CPUStoring/comparing arrays becomes significant

“Let’s just wrap it in useMemo.”
This only adds another reactive layer (more dependencies, more equality checks, more memory) without solving the root problem.

The Right Way Forward

  1. Move fetching out of hooks – Keep hooks thin; let them only provide reactivity.
  2. Simplify the pipeline – Use Promise.all (or similar) to parallelise fetches.
  3. Leverage React Query – For caching, stale‑time, retries, and UI state (loading/error).
  4. Keep a single useQuery per feature – One source of truth for UI reactivity.

Benefits

  • Debuggable – Pure functions are easy to unit‑test.
  • Predictable – Fewer moving parts, less surprise re‑renders.
  • Performant – Less memory, fewer comparisons.
  • Scalable – Adding new data requirements is a matter of adding a fetch function.
  • Maintainable – Clear separation between data fetching and UI logic.

TL;DR

  • Fetch functions → pure async, cached via queryClient.fetchQuery.
  • Mapper layer → composes multiple fetches, handles business logic (e.g., verification).
  • Single useQuery → wraps the mapper for UI reactivity.

By adopting this three‑layer approach, you avoid the pitfalls of “hook‑heavy” code and let React Query do what it does best—manage caching and state—while keeping your codebase clean, performant, and easy to reason about.

Back to Blog

Related posts

Read more »