Building a Resilient Frontend Data Layer with Suspense, Caching, and Optimistic UI
Building a Resilient Frontend Data Layer with Suspense, Caching, and Optimistic UI
In modern frontend development, the UI often feels fast and fluid, but beneath the hood there’s a web of data fetching, caching, and synchronization that can trip you up. This guide shows you a practical, end-to-end approach to architecting a resilient frontend data layer. You’ll learn patterns that work well with React, but the ideas translate to other frameworks too. We’ll cover real code, concrete decisions, and a step-by-step path from project setup to a polished user experience.
Goals and overview
- Create a robust data layer that handles loading, errors, and retries gracefully.
- Use a layered approach: server state, client cache, and UI state.
- Implement optimistic updates for responsive UX without compromising correctness.
- Leverage Suspense-like patterns (or equivalent) and a small custom cache to avoid over-fetching.
- Provide clear strategies for error boundaries, caching policies, and eviction. ### 1) Plan your data domains
Before writing code, map what data your UI consumes and how it changes.
- UI data types
- User data: profiles, preferences
- Feeds: lists of items with pagination
- Settings: feature flags, toggles
- Mutation patterns
- Create/update/delete operations
- Local edits vs. server edits
- Consistency guarantees
- Stale-while-revalidate
- Strong consistency for critical actions (e.g., voting)
Illustrative decision table:
- Use server state for freshness, client cache for speed.
- Accept eventual consistency for non-critical UI; enforce immediate consistency for critical actions. ### 2) Set up a minimal data layer
We’ll build a small, opinionated data layer that sits on top of fetch and caches results. It’s framework-agnostic in spirit but shown with React in mind.
Key components:
- ApiClient: wraps fetch with common behaviors (baseURL, headers, error handling)
- Cache: a simple in-memory map with eviction and versioning
- Resource hooks: loadResource, useResource or a Suspense-like wrapper
- Mutation helpers: mutateResource, optimistic updates
Code: ApiClient and Cache
// apiClient.js
export class ApiClient {
constructor(baseURL, defaultHeaders = {}) {
this.baseURL = baseURL;
this.defaultHeaders = defaultHeaders;
}
async request(path, options = {}) {
const url = `${this.baseURL}${path}`;
const headers = { ...this.defaultHeaders, ...options.headers };
const res = await fetch(url, { ...options, headers });
const contentType = res.headers.get('content-type') || '';
const isJson = contentType.includes('application/json');
const data = isJson ? await res.json() : await res.text();
if (!res.ok) {
const error = new Error(res.statusText || 'Request failed');
error.status = res.status;
error.data = data;
throw error;
}
return data;
}
get(path, opts) { return this.request(path, { ...opts, method: 'GET' }); }
post(path, body, opts) { return this.request(path, { ...opts, method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json', ...opts?.headers } }); }
put(path, body, opts) { return this.request(path, { ...opts, method: 'PUT', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json', ...opts?.headers } }); }
delete(path, opts) { return this.request(path, { ...opts, method: 'DELETE' }); }
}
// cache.js
export class Cache {
constructor(maxEntries = 1000) {
this.store = new Map();
this.maxEntries = maxEntries;
}
_evictIfNeeded() {
if (this.store.size <= this.maxEntries) return;
// Simple eviction: remove oldest entry
const oldestKey = this.store.keys().next().value;
this.store.delete(oldestKey);
}
get(key) {
const entry = this.store.get(key);
if (!entry) return undefined;
const { value, expiry } = entry;
if (expiry && expiry < Date.now()) {
this.store.delete(key);
return undefined;
}
return value;
}
set(key, value, ttlMs) {
const expiry = ttlMs ? Date.now() + ttlMs : undefined;
this.store.set(key, { value, expiry });
this._evictIfNeeded();
}
delete(key) { this.store.delete(key); }
clear() { this.store.clear(); }
}
3) Fetch with caching and loading states
We’ll implement a simple resource loader that caches results and provides loading/error state. This mirrors the idea of “server state” management.
Code: resource hook (React)
// useResource.js
import { useEffect, useState, useRef } from 'react';
import { ApiClient } from './apiClient';
import { Cache } from './cache';
const api = new ApiClient('https://api.example.com', { 'X-Custom-Auth': 'token' });
const cache = new Cache(500);
export function useResource(key, fetcher, { ttl = 1000 * 60 } = {}) {
const [state, setState] = useState({ status: 'idle', data: undefined, error: undefined });
const startedRef = useRef(false);
useEffect(() => {
let mounted = true;
// Check cache first
const cached = cache.get(key);
if (cached !== undefined) {
setState({ status: 'loaded', data: cached, error: undefined });
}
// Load from network
const load = async () => {
setState((s) => ({ ...s, status: 'loading' }));
try {
const data = await fetcher(api);
if (!mounted) return;
cache.set(key, data, ttl);
setState({ status: 'loaded', data, error: undefined });
} catch (err) {
if (!mounted) return;
setState({ status: 'error', data: undefined, error: err });
}
};
if (!startedRef.current) {
startedRef.current = true;
load();
}
return () => { mounted = false; };
}, [key, ttl, fetcher]);
const refresh = async () => {
try {
setState((s) => ({ ...s, status: 'loading' }));
const data = await fetcher(api);
cache.set(key, data, ttl);
setState({ status: 'loaded', data, error: undefined });
} catch (err) {
setState({ status: 'error', data: undefined, error: err });
}
};
return { ...state, refresh };
}
Example fetcher for a user:
// fetchers.js
export const fetchUser = (api, userId) => async (client) => {
// you can add query params, headers, etc.
return client.get(`/users/${userId}`);
};
export const fetchFeed = (userId, page = 1) => async (client) => {
return client.get(`/users/${userId}/feed?page=${page}`);
};
Usage in a component:
import React from 'react';
import { useResource } from './useResource';
import { fetchUser } from './fetchers';
function UserProfile({ userId }) {
const { data: user, status, error, refresh } = useResource(
`user:${userId}`,
fetchUser(null, userId) // the fetcher will receive the api client internally
);
if (status === 'loading' || status === 'idle') return <div>Loading user...</div>;
if (status === 'error') return <div>Error: {error?.message ?? 'Unknown'}</div>;
return (
<section>
<h1>{user.name}</h1>
<p>{user.bio}</p>
<button onClick={refresh}>Refresh</button>
</section>
);
}
Note: The fetcher pattern above is simplified. In a real app you’d pass the API instance and any required parameters more cleanly, possibly via context or a dedicated data manager.
4) Implement optimistic UI for mutations
Optimistic updates feel instantaneous, but you must handle rollback if the server rejects.
Example: liking a post
// mutations.js
export async function toggleLikePost(api, postId, currentLiked) {
const path = `/posts/${postId}/like`;
const method = currentLiked ? 'DELETE' : 'POST';
return api.request(path, { method });
}
Usage with optimistic update:
import { useState } from 'react';
import { ApiClient } from './apiClient';
const api = new ApiClient('https://api.example.com');
export function useOptimisticLike(postId, initialLiked) {
const [liked, setLiked] = useState(initialLiked);
const [status, setStatus] = useState('idle');
const toggle = async () => {
const previous = liked;
// optimistic update
setLiked((v) => !v);
setStatus('mutating');
try {
await api.request(`/posts/${postId}/like`, { method: previous ? 'DELETE' : 'POST' });
setStatus('idle');
} catch (e) {
// rollback on error
setLiked(previous);
setStatus('error');
}
};
return { liked, status, toggle };
}
ui integration:
function PostActions({ post }) {
const { liked, status, toggle } = useOptimisticLike(post.id, post.liked);
return (
<button onClick={toggle} disabled={status === 'mutating'}>
{liked ? 'Unlike' : 'Like'} {status === 'mutating' ? '…' : ''}
</button>
);
}
Tips:
- Always show a fallback UI during mutation (e.g., a spinner or disabled button).
- Store the previous state to rollback if the mutation fails.
-
For non-idempotent actions, consider server-side deduplication to avoid duplicates.
5) Error handling and retry strategies
Global error boundary for UI-level crashes.
Network errors should trigger a retry with backoff.
Distinguish transient vs. persistent errors.
Simple retry helper:
export async function withRetry(fn, { retries = 3, delayMs = 500 } = {}) {
let lastError;
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (e) {
lastError = e;
await new Promise((r) => setTimeout(r, delayMs * (i + 1)));
}
}
throw lastError;
}
Usage:
const data = await withRetry(() => api.get('/data'), { retries: 4, delayMs: 400 });
UI pattern:
- Show a non-blocking retry button in the error state.
-
Offer a "Retry all" action if multiple resources failed.
6) Pagination, infinite scroll, and cache coherence
Cache pages independently with a stable key:
feed:userId:page:2.Invalidate or refresh pages when a mutation affects the data (e.g., new post in feed).
Example: prefetch next page after loading current
// in a feed component
useEffect(() => {
// after loading page N, prefetch N+1
if (pageLoaded) {
fetcher(api, userId, page + 1).then((data) => {
cache.set(`feed:${userId}:page:${page + 1}`, data, ttl);
});
}
}, [page, pageLoaded]);
Infinite scroll pattern:
- Maintain a small cached page cache.
- Stop prefetching when user stops scrolling or data is exhausted. ### 7) Server-Side rendering considerations
If you’re rendering on the server (Next.js, Remix), hydrate with initial data to avoid a loading state.
- Use server-provided initial data to seed the cache.
- Revalidate on the client after hydration to ensure freshness.
A practical approach:
- On the server, fetch and embed data in the HTML as initial state.
-
On the client, initialize the cache with that state and proceed as usual.
8) Observability: logging, metrics, and user experience signals
Capture latency, cache hit rate, and mutation success rate.
Instrument error rates per endpoint.
Show user-friendly indicators: skeletons for loading, inline toasts for errors.
Simple telemetry idea:
- Collect metrics in a central store (e.g., window.__telemetry or a tiny analytics hook).
- Log cache hits/misses and mutation outcomes.
Example minimal hook:
export function useTelemetry() {
const log = (event, payload) => {
// replace with real analytics in production
console.debug('[telemetry]', event, payload);
};
return { log };
}
9) Accessibility and UX considerations
- Ensure loading indicators are announced by screen readers (aria-live regions).
- Provide clear error messages with actionable steps (Retry, Contact support).
-
Maintain visual stability during data updates to avoid jank.
10) Deployment and performance tips
Keep cache TTLs sane; too long can serve stale data, too short increases fetches.
Use HTTP cache headers when possible for server responses to leverage CDNs.
-
Prefer optimistic UI for high-signal interactions (likes, saves) and conservative updates for critical data (account changes, payments).
11) A small, end-to-end example app
What you’ll build:
- A user profile page with:
- Cached user data
- A feed list with pagination
- A “follow” mutation with optimistic update
- Global error boundary and retry UI
Folder layout (conceptual):
- src/
- apiClient.js
- cache.js
- fetchers.js
- useResource.js
- mutations.js
- components/
- UserProfile.jsx
- Feed.jsx
- pages/
- UserPage.jsx
High-level flow:
- On UserPage mount, useResource loads user data (checks cache, fetches if needed).
- Feed component uses a similar hook to load and paginate data.
- Follow button uses optimistic update via a small mutation helper.
Code excerpts (tight integration sketch):
// UserPage.jsx
import React from 'react';
import { useResource } from '../useResource';
import { fetchUser } from '../fetchers';
import { Feed } from './Feed';
export function UserPage({ userId }) {
const { data: user, status: userStatus } = useResource(
`user:${userId}`,
fetchUser(null, userId)
);
if (userStatus === 'loading') return <div>Loading user...</div>;
if (userStatus === 'error') return <div>Error loading user</div>;
return (
<section>
<h2>{user.name}</h2>
<p>{user.bio}</p>
<Feed userId={userId} />
</section>
);
}
// Feed.jsx
import React from 'react';
import { useResource } from '../useResource';
import { fetchFeed } from '../fetchers';
export function Feed({ userId }) {
const { data: items, status, refresh } = useResource(
`feed:${userId}:page:1`,
fetchFeed(userId, 1)
);
if (status === 'loading') return <div>Loading feed...</div>;
if (status === 'error') return <div>Failed to load feed</div>;
return (
<div>
{items?.length ? (
items.map((it) => (
<div key={it.id} className="post">
<h4>{it.title}</h4>
<p>{it.summary}</p>
</div>
))
) : (
<div>No posts yet.</div>
)}
<button onClick={refresh}>Refresh Feed</button>
</div>
);
}
This is a compact blueprint. In a real project, you’d flesh out error boundaries, a shared context for the API client, and more sophisticated cache eviction policies.
12) Next steps
- Start small: implement ApiClient and Cache, then a simple useResource hook for one endpoint.
- Introduce an optimistic mutation for a single action (e.g., toggle a bookmark) to validate the mental model.
- Add an error boundary, and a simple retry UI to handle transient failures.
- Gradually expand to pagination, prefetching, and Suspense-like orchestration if your framework supports it.
If you’d like, I can tailor this to a specific framework (React, Vue, Svelte) and wiring (Vite, Next.js, Remix) you’re using, and provide a repo scaffold with these patterns wired up. Would you prefer a React + Vite setup or a Next.js-based example?
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)