DEV Community

WingsDevelopment
WingsDevelopment

Posted on

The Web2 Mental Model Doesn’t Work in Web3

The Right Way To Fetch On-Chain Data

Goals of this Post

  • Developer friendliness
  • Performance improvements
  • Simpler code maintenance
  • Smarter caching
  • Easy, predictable data invalidation

Also the goal here is not to build the perfect, all-encompassing abstraction that works for everyone.

The goal is to create a simple, scalable baseline that any developer can understand, adopt quickly, and extend as their project grows.

React Query completely changed the way many of us think about data and state in React. It’s an incredible library - but in web3, a lot of us (myself included) were unintentionally using it the wrong way.

Let’s explore why.

The Web2 Mental Model Doesn’t Work in Web3

In a typical web2 front-end, you fetch well-prepared data from a backend or database - a single source of truth:

It’s clean. One request → one response → one useQuery.

But in web3, your "backend" is:

  • multiple contracts
  • multiple RPC endpoints
  • multiple formats
  • sometimes a subgraph
  • and no server to aggregate or prepare data

So the exact same UI ends up looking more like this:

And this is where things go wrong.

Fetching data from 3 contracts?

You now have:

  • 3 different hooks
  • 3 sets of enabled logic
  • 3 query keys
  • 3 loading/error states
  • memoized transformations on top
  • selectors
  • sorting
  • re-renders caused by unstable references

It becomes too complex. Too reactive. Too hard to maintain.

A single hook grows to 500+ lines of conditions and nested hooks.

So what do we do?

We go back to what works.

A Simpler Model: Fetch → Mapper → UI

We borrow inspiration from backend architecture — where systems typically have three layers:

  • Presentation
  • Services
  • Infrastructure

For web3 front-ends, the equivalent becomes:

UI → Mappers → Fetchers

This model restores predictability, improves performance, simplifies debugging, and keeps caching under control.

Let’s break it down.


Layer 1 — Fetchers (RPC/Network + Cache)

The fetch layer is responsible for:

  • Implementing the actual RPC / HTTP call
  • Caching it using React Query’s non-hook API
  • Nothing else

This is the key insight:

You do not use hooks in this layer. Get queryClient directly instead of useQueryClient.

You use:

queryClient.fetchQuery()
Enter fullscreen mode Exit fullscreen mode

React Query still handles:

  • caching
  • staleTime
  • retries
  • background updates
  • and all other non reactive aspects from fetchQuery

But without introducing reactivity where it doesn’t belong.

Take a look at the example of how to fetch decimals, balance and use mapper to map those values:

export const getTokenDecimalsQuery = (token: Address, chainId: number) => ({
  ...readContractQueryOptions(getWagmiConfig(), {
    abi: erc20Abi,
    address: token,
    chainId,
    functionName: "decimals",
    args: [],
  }),
  staleTime: Infinity,
});

export async function fetchTokenDecimals(token: Address, chainId: number) {
  return getQueryClient().fetchQuery(getTokenDecimalsQuery(token, chainId));
}
Enter fullscreen mode Exit fullscreen mode

^ Here, the fetcher caches the token decimals forever.

export const getTokenBalanceQuery = (
  token: Address,
  chainId: number,
  account: Address,
) => ({
  ...readContractQueryOptions(getWagmiConfig(), {
    abi: erc20Abi,
    address: token,
    chainId,
    functionName: "balanceOf",
    args: [account],
  }),
  staleTime: 1 * 1000, // 1 minute
});

export async function fetchTokenBalance(
  token: Address,
  chainId: number,
  account: Address,
) {
  return getQueryClient().fetchQuery(
    getTokenBalanceQuery(token, chainId, account),
  );
} 
Enter fullscreen mode Exit fullscreen mode

^ Here, the fetcher caches the balance for 1 minute.

No matter:

  • which mapper calls it
  • how many screens use it
  • how many components reference it

This RPC call balanceOf only happens once every 1 minute at most. (Or only once in decimals case)

Your UI stays fast, your app stays stable, and caching stays centralized.

Layer 2 — Mappers (Business Logic & Data Shaping)

The mapper layer is where everything comes together.

It:

  • understands what the UI needs
  • understands how the chain/API supplies data
  • calls multiple fetchers
  • formats and derives values
  • combines data into a clean, ready-to-use structure
  • uses plain JS (if/else, map, for, switch, early returns)

Because fetchers are non-hook functions, mappers are also non-hook functions.

So you can use normal control flow without React’s limitations.

This is where complexity shrinks by 10x.

Here is the example

import { formatBigIntToViewNumber, ViewNumber } from "react-display-value"

export async function displayBalanceMapper(params: {
  token: Address;
  chainId: number;
  account: Address;
}): Promise<ViewNumber> {
  const { token, chainId, account } = params;

  // Fetch metadata (forever cache) + balance (1m cache) in parallel
  const [decimals, symbol, rawBalance] = await Promise.all([
    fetchTokenDecimals(token, chainId), // usually hits the cache (only first time doesn't)
    fetchTokenSymbol(token, chainId), // usually hits the cache (only first time doesn't)
    fetchTokenBalance(token, chainId, account), // fresh every minute
  ]);

  return { 
        // format (prepare) your value for view, don't let raw values go any further
      balanceFormatted: formatBigIntToViewNumber(rawBalance, decimals, symbol)
  };
}
Enter fullscreen mode Exit fullscreen mode

Mappers become the business logic brain of your project.

This mapper is simple, but once you try to build anything more complex using the traditional “hook-heavy” pattern - with a separate useQuery for every tiny data point - things quickly spiral out of control:

  • multiple enabled conditions
  • multiple loading/error states
  • nested hooks
  • deeply branching logic
  • no clean way to return emptyState early
  • no way to throw error early

Worse, the moment you need to fetch data for anything dynamic - lists of tokens, vaults, markets, positions, etc. — the hook-heavy approach falls apart. Mapping over an array now requires useQueries, which introduces even more reactivity, more conditions, and still doesn’t solve conditional fetching cleanly.

No easy gold old for loop to fetch things!

You need sortable data for table? Not a problem lets just create displayBalancesMapper :

export async function displayBalancesMapper(params: {
  tokens: Address[];
  chainId: number;
  account: Address;
}): Promise<DisplayBalanceRow[]> {
  const { tokens, chainId, account } = params;
  if (!tokens?.length) return []; // simple empty array return

  // Kick off all token rows in parallel; cache handles duplicates efficiently
  const rows = await Promise.all(
    tokens.map((token) =>
      displayBalanceMapper({ token, chainId, account }),
    ),
  );

  // Example: sort descending by normalized amount (for UI tables)
  rows.sort((a, b) => b.sortKey - a.sortKey);

  return rows;
}
Enter fullscreen mode Exit fullscreen mode

Layer 3 — UI (Dumb Components, Smart Data)

Once data reaches the UI layer, everything is simple again.

UI components should:

  • display formatted data
  • avoid domain logic
  • avoid calling fetchers
  • avoid doing chain calculations
  • rely entirely on React Query cache and mapper output

The UI and hook should be "dumb":

export function getDisplayBalanceQuery(
  token?: Address,
  chainId?: number,
  account?: Address,
) {
  return {
    queryKey: ["useDisplayBalance", chainId, token, account] as const,
    queryFn: () => displayBalanceMapper({ token: token!, chainId: chainId!, account: account! }),
    enabled: Boolean(token && chainId && account),
    // UI-level “hide mapper” cache to reduce re-renders from formatting
    staleTime: 30_000, // 15s window; tweak 15sec–1min as needed
  };
}

export function useDisplayBalance(
  token?: Address,
  chainId?: number,
  account?: Address,
) {
  return useQuery(getDisplayBalanceQuery(token, chainId, account));
}
Enter fullscreen mode Exit fullscreen mode

And formatting should already be done long before rendering:

import { DisplayTokenValue, DisplayTokenAmount } from "react-display-value"

// handels loading, error states but also renders values perfectly
// example: <span customStyleHere>$</span><span customStyleHere>34.22</span> and more..
<DisplayTokenValue {...rest} {...data?.balanceFormatted} />
// user sees '$42.22' with option to style $ sign separetely
Enter fullscreen mode Exit fullscreen mode

For example, using the number formatting logic from our new library that does heavy lifting and keeping things broad enough so that mapper can satisfy any UI needs:

👉 https://github.com/WingsDevelopment/react-display-value

This removes the need for useMemo entirely.

Every piece of data you render is already:

  • formatted
  • normalized
  • rounded
  • signed
  • symbolized
  • display-ready

Your useQuery in the UI becomes nothing more than a reactive wrapper around a mapper:

useQuery({
  queryKey: ["something", params],
  queryFn: () => mapper(params),
});
Enter fullscreen mode Exit fullscreen mode

No complexity.
No extra fetching.
No nested hooks.

Preventing Unnecessary Re-renders With UI-Level Cache

Here’s another powerful trick:

You can “hide” your mapper inside a useQuery with a short cache window (e.g. 15–60 seconds or more).

This way:

  • heavy formatting
  • expensive maps
  • large number reductions
  • symbol/sign parsing
  • and other CPU-heavy mapping logic

…will run once every 15–60 seconds or what ever your need is, even if the component re-renders multiple times.

This turns the mapper into a mini “UI cache layer,” which dramatically reduces re-renders and CPU churn in complex dashboards or tables.

It’s an incredibly simple optimization, but the payoff is huge, especially in data-heavy DeFi applications.

Final Thoughts

React Query is powerful - but web3 and on-chain data fetching changes how we must use it.

The traditional approach (lots of hooks, lots of memos, lots of reactivity) breaks down as soon as your application grows beyond a handful of RPC calls.

By adopting the Fetch → Mapper → UI architecture:

✔ You isolate complexity

✔ You centralize caching

✔ You remove nested hooks

✔ You avoid re-render storms

✔ You keep React Query exactly where it belongs

✔ You get predictable behavior even at scale

✔ You make your codebase far easier to maintain

This approach doesn’t try to solve everything.

It gives you a clean, scalable foundation that any teammate can understand, extend, and trust - the opposite of the fragile hook soup most web3 apps end up with.

In a world where front-ends must behave like backends, this architecture makes the difference between a codebase that collapses under its own weight and a codebase that grows gracefully.


More to come

This article set the baseline. Next, I’ll publish short, focused deep-dives that build on the Fetch → Mapper → UI pattern:

  1. Composing multiple sources of data
  2. Caching strategies beyond - group invalidation, singular invalidation
  3. Recognize data direction - When useMemo is actually the right tool
  4. Error handling & partial data
  5. Testing & observability

Top comments (0)