The Architecture Mistakes That Slowly Kills Large React Projects
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.
| Source | Characteristics |
|---|---|
| Backend‑provided data | cached, aggregated, slow‑changing |
| On‑chain data | live, reactive, per‑user |
The Fetch → Mapper → UI pattern is exactly what makes this possible:
- Fetchers isolate how and where the data comes from (backend or chain).
- Mappers combine, normalize, format, and reconcile those two sources.
- 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) |
|---|---|
| 10 | 200 |
| 500 | 10 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.
| Web2 | Web3 |
|---|---|
| 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
useQuerycalls 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
- Single source of truth – one query returns a fully‑formed data object.
- No cascade of loading flags – the hook’s
isLoadingreflects the whole payload. - Easier to test & maintain – the mapper is a pure function that can be unit‑tested.
- Reduced
useMemousage – 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
useQueryhooks 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 caching –
staleTimehandles expiration automatically. - No hook‑inside‑hook – The higher‑level operation can call
fetchOwnerdirectly, 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
- Scattered caching logic – Every internal hook needs its own
enabled,staleTime, etc. - Hard to change requirements – Adding a pre‑check (e.g., “is the vault verified?”) forces updates in dozens of places.
- Performance overhead – Many
useMemocalls, dependency arrays, and query keys cause unnecessary re‑renders.
A Cleaner Flow
- Fetch functions – Pure async functions that return data (e.g.,
fetchOwner,fetchIsVerified). - Mapper layer – A function that calls as many fetch functions as needed (often with
Promise.all). useQuerywrapper – A singleuseQuerythat 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
| Issue | What Happens |
|---|---|
Many useQuery calls | 7+ query keys → many independent caches |
Multiple useMemo wrappers | 3‑5 extra dependency arrays per hook |
| 10+ dependency arrays | React must compare them on every render |
| Scaling | More components → more arrays & keys → cascade of re‑renders |
| Memory & CPU | Storing/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
- Move fetching out of hooks – Keep hooks thin; let them only provide reactivity.
- Simplify the pipeline – Use
Promise.all(or similar) to parallelise fetches. - Leverage React Query – For caching, stale‑time, retries, and UI state (loading/error).
- Keep a single
useQueryper 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.