Since a lot of people showed interest in my last blog post about architecture, I decided to start a blog series to address common questions and dilemmas you might be facing.
Context: Why This Series Exists
If you haven’t seen it yet, this post is a continuation of a previous article I wrote while working on Euler’s frontend architecture:
The Architecture That Fixed Euler’s Frontend Performance
Taking a deeper look at fetch → mapper → UI approach:
The Web2 Mental Model Doesn’t Work in Web3
Introduction
Let’s begin by focusing on architectural problems and the bigger picture.
One of the most common issues in front-end applications is handling data fetching from multiple sources. On the happy path, everything works fine, but the real question is:
What happens when one or more of those sources fail?
In DeFi applications, data usually comes from multiple places:
- the blockchain (RPC),
- subgraphs,
- third-party services (Merkle distributors, reward APIs),
- price feeds, and similar APIs.
Not every query has or should have all of these dependencies tightly coupled together.
But let’s look at a simple example that almost every DeFi app needs:
Fetch token balances, display them in a table, and also show the total value in USD.
At first glance, this looks trivial.
In practice, it’s not.
The Core Problem
With a typical hook-based approach, the main issue is that these requests are not synchronous.
There’s no reliable way to know when all balances, symbols, decimals, and prices have been fetched.
And poor UX is only one reason to look for a different approach. There are many others:
- Proper caching — reducing RPC call costs
- Leveraging wagmi batching
- Minimal query cache invalidation
- Cleaner code and easier maintenance
- Better testability
- Increased robustness and fewer bugs
A Mapper-Based Approach
Let’s start with a mapper-based solution.
Here’s an example of a balancesMapper that:
- fetches balances,
- fetches token prices,
- calculates the total USD value.
While fetchers are handling caching under the hood, more on this here.
export async function balancesMapper(params: {
tokens: Address[]
account: Address
options?: BalancesMapperOptions
}): Promise<DisplayBalances> {
const { tokens, account, options } = params
const [balances, pricesResult] = await Promise.all([
// Let wagmi batch these RPC calls
// If this fails, the whole mapper fails
Promise.all(
tokens.map((token) =>
balanceMapper({ token, account, options })
)
),
// Different data source (HTTP fetch), so we make this safe
// If this fails, we can still display balances
safeCall(() => tokenPricesMapper({ tokens, options })),
])
const totalValueUsd: SafeResult<ViewNumber | undefined> =
pricesResult.error
? { data: undefined, error: pricesResult.error }
: {
data: calculateTotalValue(balances, pricesResult.data),
error: undefined,
}
return { balances, totalValueUsd }
}
Here we’re fetching data from two different sources:
- the blockchain (balances),
- a price API.
From a UI perspective, the goal is to display as much information as possible.
- If the price API fails → we can still show balances.
- If the RPC call fails → price data becomes useless in this scenario.
So this query should fully fail if the RPC call fails, but partially degrade if the price API does.
Designing a Mapper Starts With the UI
When creating a query mapper, the first question should not be:
“How do I fetch this data?”
It should be:
“What does the UI actually need, and what is it allowed to show if parts of this fail?”
This is the most important responsibility of a mapper.
A mapper is not just a place to combine fetch calls — it’s where you define failure boundaries.
For every mapper, you should explicitly decide:
- Which data is required for the UI to function
- Which data is optional and can fail without breaking the experience
- When partial data is acceptable
- When the entire query should be considered failed
In the balances example above, that decision is intentional:
Balances are required
If fetching balances fails, the UI cannot function — the mapper fails.Prices are optional
If price fetching fails, we still render balances, but we showtotalValueUsdas unavailable and expose the error for the UI.
This is the key difference versus hook-heavy composition:
the UI no longer needs to re-implement “what is allowed to compute right now?” on every screen.
Also, remember: this mapper is not an “ultimate solution.” You should still separate concerns as much as possible.
But when you do need to merge data, a mapper is the right place to make explicit decisions about partial failure and full failure.
And don’t be afraid to reuse the same cache with different mappers. If tokenPricesMapper is already caching prices, a prices-only view can reuse it without refetching or duplicating logic.
The safeCall Wrapper
To support partial failures, we use a small utility:
export interface SafeResult<T> {
data: T | undefined
error: Error | undefined
}
export async function safeCall<T>(
fn: () => Promise<T>
): Promise<SafeResult<T>> {
try {
const data = await fn()
return { data, error: undefined }
} catch (error) {
return { data: undefined, error: error as Error }
}
}
This prevents errors from being thrown while still exposing them to the caller.
Your UI can now say:
“Here are your balances, but I couldn’t fetch price data because of this error.”
That’s a first-class, explicit state, not an accidental side effect.
Cache Reuse and Separation of Concerns
You might be wondering:
“What if I want to display token prices even if I can’t fetch balances?”
For example:
- ETH = $30k
- BTC = $90k
That’s the beauty of this approach — we’re already calling tokenPricesMapper, which caches pricing results.
A prices-only screen or widget can reuse the same cached data without duplicating logic or triggering additional requests.
This separation of concerns also gives us clean and predictable cache invalidation, but more on that in a later post.
Comparison: A Hook-Heavy Approach
Now let’s implement the same feature using a more traditional, hook-heavy pattern.
We’ll pretend we have standard hooks that internally call plain fetch functions:
-
useBalances()→balanceOf -
useTokenSymbols()→symbol -
useTokenDecimals()→decimals -
useTokenPrices()→ price API
No mappers involved.
export function useDisplayBalancesWithHooks(
tokens: Address[] | undefined,
account: Address | undefined,
settings: SimulationSettings = {},
): UseDisplayBalancesResult {
const { simulateLoading, simulatePriceError, simulateRpcError } = settings
const enabled = Boolean(tokens?.length && account)
const balancesQuery = useBalances({ tokens, account, enabled, simulateRpcError })
const symbolsQuery = useTokenSymbols({ tokens, enabled, simulateRpcError })
const decimalsQuery = useTokenDecimals({ tokens, enabled, simulateRpcError })
const pricesQuery = useTokenPrices({ tokens, enabled, simulatePriceError })
const isBalancesLoading =
simulateLoading ||
balancesQuery.isLoading ||
symbolsQuery.isLoading ||
decimalsQuery.isLoading
const isBalancesError =
Boolean(balancesQuery.error) ||
Boolean(symbolsQuery.error) ||
Boolean(decimalsQuery.error)
const isPriceLoading = pricesQuery.isLoading
const isPriceError = Boolean(pricesQuery.error)
const priceError = (pricesQuery.error as Error) ?? null
const balances = useMemo((): DisplayBalance[] => {
if (!tokens?.length) return []
return tokens
.map((token, index) => {
const rawBalance = balancesQuery.data?.[index]
const symbol = symbolsQuery.data?.[index]
const decimals = decimalsQuery.data?.[index]
if (rawBalance === undefined || !symbol || decimals === undefined) {
return null
}
return {
address: token,
rawBalance,
symbol,
decimals,
balanceFormatted: formatBigIntToViewTokenAmount({
bigIntValue: rawBalance,
symbol,
decimals,
}),
}
})
.filter((b): b is DisplayBalance => b !== null)
}, [tokens, balancesQuery.data, symbolsQuery.data, decimalsQuery.data])
const totalValueUsd = useMemo(() => {
if (isBalancesLoading || isBalancesError || balances.length === 0) {
return undefined
}
if (pricesQuery.isLoading || pricesQuery.isError || !pricesQuery.data) {
return undefined
}
return calculateTotalValue(balances, pricesQuery.data)
}, [
isBalancesLoading,
isBalancesError,
balances,
pricesQuery.isLoading,
pricesQuery.isError,
pricesQuery.data,
])
return {
balances,
totalValueUsd,
isBalancesLoading,
isBalancesError,
isPriceLoading,
isPriceError,
priceError,
}
}
What’s Actually Happening Here
Most of this hook is not business logic — it’s coordination logic:
- Merging multiple async sources
- Defining what “loading” means across queries
- Deciding which errors are fatal and which are optional
- Preventing UI glitches
- Aligning data by index across hooks
- Figuring out when it’s safe to compute derived values
In other words, a hook-heavy approach forces you to repeatedly answer:
“What is the app allowed to calculate right now?”
And that logic tends to leak into every screen.
Demo
If you prefer seeing these ideas in action instead of just reading about them, I’ve put together a small interactive demo that showcases the patterns discussed in this post.
Live demo:
https://react-clean-code-tutorials.vercel.app/
Repo:
https://github.com/WingsDevelopment/react-clean-code-tutorials
Summary
- Fetching from multiple async sources is the real complexity in DeFi front ends.
- Hook-heavy solutions push orchestration logic into UI layers.
- Mapper-based approaches make data fetching atomic, predictable, and testable.
- Partial failures become explicit, not accidental.
- Cache reuse and invalidation become easier by design.
- UI focuses on rendering, not coordination.
What’s Next?
Planned follow-up posts:
- useQueries vs Promise.all: Understanding the Differences
- Selectors vs Mappers: Understanding the Trade-offs
- Proper Data Mapping: Format Once, Display Many Variations
- Perfect Caching Mechanism: Saving Money on RPC Calls without Increasing Development Cost
I’d love input from the community:
What would you like to understand better next?
- caching strategies?
- error handling?
- testability?
- performance trade-offs?
Let me know — the next posts will be driven by that.

Top comments (0)