DEV Community

WingsDevelopment
WingsDevelopment

Posted on

Handling Multiple Data Sources in DeFi Frontend Architecture

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 }
}
Enter fullscreen mode Exit fullscreen mode

Here we’re fetching data from two different sources:

  1. the blockchain (balances),
  2. 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 show totalValueUsd as 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 }
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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)