Skip to main content

Data Fetching: Common Patterns & Standards

Archibald Fetch Interactions and Standards

Fetch Function Example

function Todos() {
const todosFetch = useFetch('todos', () => actionGetTodos());

return (
<ul>
{
todosFetch.data?.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))
}
</ul>
);
}

Fetch Keys

Fetched data for every unique fetch key will be cached in DataClient. You can inspect currently cached values in browser console with window.__DATA_CLIENT__.dataCache.values in development mode only

useFetch keys can be of type string, number, null. or an array of these types.

import { useFetch } from '@archibald/client';

const RETURN_VALUE = useFetch<DATA_TYPE>(PARAMETERS);

Mutations with Cache Updates

To update values in Dataclient cache, use useMutation with the same key.

function Todos() {
const todosFetch = useFetch('todos', () => actionGetTodos());
const { mutate } = useMutation('todos');

function addTodo(){
mutate(actionAddTodo())
}

return (
<>
<button onClick={addTodo}>Add todo</button>
<ul>
{todosFetch.data?.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</>
);
}

Why

  • Consistency avoids cache collisions
  • Typed fetch functions improve TypeScript safety
  • Mutations that update cache reduce unnecessary refetches

Caching & Data Client

Cache Keys

The fetchKey is critical for caching. It acts as a dependency array for the cache.

  • String Key: 'users'
  • Array Key: ['users', userId, { filter: 'active' }]

The Data Client uses these keys to serialize the request and store the result. If any part of the key changes, a new fetch is triggered (unless enabled is false).

Cache Lifetime & Persistence

  • ttl (Time To Live): Defines how long (in ms) data remains "fresh" in the cache. Defaults to 15 minutes. After this time, the data is considered stale and may be refetched or garbage collected.
  • enduring: If set to true, the cache entry is protected from automatic garbage collection. This is useful for critical data that must remain available (e.g., user session info), but should be used sparingly to avoid memory leaks.

Reactivity with useSyncExternalStore

The Data Client cache is external to the React tree. To ensure your component updates immediately when the cache is updated (e.g., by another component or a mutation), useFetch utilizes useSyncExternalStore. This ensures tearing does not occur and the UI is always consistent with the cache.

Server-Side Rendering (SSR)

useFetch is designed to work seamlessly with SSR.

  1. Execution: The fetch data function is executed on the server during the render pass.
  2. Dehydration: The result is stored in the Data Client and dehydrated (sent to the client as JSON).
  3. Hydration: On the client, useFetch initializes with this data, avoiding a double fetch on mount.

Important: Ensure your fetch data function is isomorphic (runs on both server and client). Avoid accessing window or document directly in the fetch function.


Suspense + Fetch Behavior

What Suspense Does

  • Suspense allows React to wait for async operations before rendering
  • Archibald Fetch supports this via suspense: true

When a component is wrapped in Suspense, React expects that the component might not be ready yet because it's waiting on some async data.

If the component throws a Promise, React shows the fallback UI until the Promise resolves. useFetch supports this pattern by throwing:

  • a Promise (when loading)
  • an Error (when the fetch fails)

So, Archibald useFetch plugs directly into React's Suspense architecture.

Loading Behavior

useFetch(
FETCH_KEYS.USERS, // key
() => actionGetUsers(), // action
{ suspense: true } // options
);

What happens during loading?

  • Throws a Promise while data is loading
  • The nearest Suspense boundary catches it
  • The fallback is displayed

Why

  • Promotes declarative loading states
  • Reduces repetitive isLoading logic
  • Enables nested progressive loading

Because throwing a Promise is how Suspense tells React: "Pause rendering this component; show the Suspense fallback instead"

Why the Suspense Fallback appears

When Archibald useFetch throws a Promise, React catches it and renders the fallback.

Example:

<Suspense fallback={<LoadingSpinner/>}>
<UserList />
</Suspense>;

Here's the sequence:

  1. UserList calls useFetch
  2. useFetch has suspense: true
  3. Data is not yet available → useFetch throws a Promise
  4. React Suspense catches it → shows LoadingSpinner
  5. When the Promise resolves → React re-renders UsersList with real data

Error Boundary Behavior

Centralized Error Handling

useFetch(key, action, {
suspense: true,
errorBoundary: true
});

Component Setup

<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<LoadingSpinner />}>
<UserList />
</Suspense>
</ErrorBoundary>

Why

  • Errors are propagated to the nearest Error Boundary
  • Prevents component errors from crashing the entire app
  • Keeps UI predictable and fault-tolerant

Fallback Resolution Hierarchy

Why

  • Closest boundary takes precedence to isolate loading/errors
  • Avoids unnecessary global fallbacks
  • Provides fine-grained control over UX

Route-Level vs Page-Level Boundaries

Route-Level Only

  • Lazy-loaded page triggers loading → route fallback shown

Page-Level Only

  • Inner component triggers loading → page fallback shown

Nested Example

<Route element={<Suspense fallback={<RouteLoader />}><Page /></Suspense>} />

Page:

<Suspense fallback={<PageLoader />}>
<Inner />
</Suspense>
  • Inner triggers loading → PageLoader shows
  • RouteLoader ignored

Why

  • Local boundaries reflect the closest relevant state
  • Reduces full-page flashes and improves perceived performance

Lazy Loading + Suspense Interactions

Example Lazy Component

const LazyWidget = Loadable({
factory: () => import('./Widget')
});

Combined with Suspense + React Fetch

  • Lazy component throws a Promise for module loading
  • useFetch(key, () => {}, { suspense: true }) throws a Promise for data
  • Each boundary renders its own fallback in order

Timeline

Why

  • Allows progressive hydration
  • Keeps fallback handling modular
  • Ensures errors and loading are isolated

Combined Behaviors and Async Waterfalls

  • Multiple async sources (lazy + data) can cause sequential fallbacks
  • Can lead to waterfall loading if not planned
  • Parallel prefetching can reduce total load time

Waterfall Effect

Parallel Loading

Recommendations

  • Group related lazy imports
  • Prefetch critical data
  • Use skeletons for smoother progressive loading
  • Reduce deeply nested Suspense boundaries

Risks & Pitfalls

IssueCauseMitigation
Multiple fallback flashingNested Suspense + lazy + dataConsolidate boundaries, use skeletons
Deeply nested boundariesIndependent async operationsMerge boundaries where possible
Missing SuspenseLazy or fetch throwsAlways wrap async code in Suspense
Route transitionsLazy + data fetchPrefetch modules and/or data
Lazy preload missingRoute starts fetch lateUse preload hints
Error boundaries mismatchLazy-loaded component import failsWrap lazy components in proper ErrorBoundary
Async waterfallSequential lazy → dataParallel loading, prefetch data
Memory leaksOverusing enduring: trueOnly mark critical global data as enduring
Stale dataTTL too longAdjust TTL based on data volatility
Excessive fetchesTTL too short or ttl: 0Use appropriate TTL with refetch

Why These Design Patterns Matter

  • Ensures consistent, predictable user experience
  • Avoids unnecessary crashes
  • Enables fine-grained control over loading and error states
  • Improves perceived performance and UX
  • Simplifies developer mental model for complex async UI
  • Optimizes memory usage and network efficiency