import { useEffect, useRef, useState } from "react";
export type AsyncState<T> =
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: T };
export function useAsyncData<T>(
fetcher: () => Promise<T>,
deps: unknown[] = []
): { state: AsyncState<T>; reload: () => void } {
const [state, setState] = useState<AsyncState<T>>({ status: "loading" });
const [nonce, setNonce] = useState(0);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
setState({ status: "loading" });
fetcher()
.then((data) => {
if (mounted.current) setState({ status: "success", data });
})
.catch((err: unknown) => {
const error = err instanceof Error ? err : new Error(String(err));
if (mounted.current) setState({ status: "error", error });
});
return () => {
mounted.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, nonce]);
return { state, reload: () => setNonce((n) => n + 1) };
}
import React from "react";
import "./skeleton.css";
type SkeletonProps = {
width?: number | string;
height?: number | string;
circle?: boolean;
className?: string;
};
export function Skeleton({ width, height = 16, circle, className }: SkeletonProps) {
return (
<span
role="status"
aria-busy="true"
aria-hidden="true"
className={`skeleton${circle ? " skeleton--circle" : ""}${className ? " " + className : ""}`}
style={{ width, height, borderRadius: circle ? "50%" : undefined }}
/>
);
}
export function SkeletonText({ lines = 3 }: { lines?: number }) {
return (
<div className="skeleton-text" aria-hidden="true">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} height={12} width={i === lines - 1 ? "55%" : "100%"} />
))}
</div>
);
}
import React from "react";
import { useAsyncData } from "./useAsyncData";
import { Skeleton, SkeletonText } from "./Skeleton";
type User = { id: string; name: string; bio: string; avatarUrl: string };
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`Failed to load user (${res.status})`);
return res.json();
}
export function UserProfileCard({ userId }: { userId: string }) {
const { state, reload } = useAsyncData(() => fetchUser(userId), [userId]);
if (state.status === "loading") {
return (
<div className="profile-card">
<Skeleton circle width={64} height={64} />
<div className="profile-card__body">
<Skeleton width={"40%"} height={20} />
<SkeletonText lines={3} />
</div>
</div>
);
}
if (state.status === "error") {
return (
<div className="profile-card profile-card--error">
<p>{state.error.message}</p>
<button onClick={reload}>Retry</button>
</div>
);
}
const user = state.data;
return (
<div className="profile-card">
<img className="profile-card__avatar" src={user.avatarUrl} alt={user.name} />
<div className="profile-card__body">
<h3>{user.name}</h3>
<p>{user.bio}</p>
</div>
</div>
);
}
.skeleton {
display: inline-block;
background: linear-gradient(90deg, #e8eaed 25%, #f2f4f6 37%, #e8eaed 63%);
background-size: 400% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
border-radius: 4px;
}
.skeleton--circle {
flex: 0 0 auto;
}
.skeleton-text {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
@keyframes skeleton-shimmer {
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}
@media (prefers-reduced-motion: reduce) {
.skeleton { animation: none; }
}
Skeleton loaders replace spinners with grey placeholder shapes that mimic the final content's layout. This reduces perceived latency and prevents layout shift, because the page reserves the exact space the real content will occupy. This snippet shows a small, reusable system built around three collaborating files.
In useAsyncData hook, a generic fetching hook returns a discriminated union rather than a loose { data, loading, error } bag. The state is one of loading, error, or success, which forces callers to handle every case in the type system. The hook tracks a mounted ref to avoid setting state after unmount, and re-runs whenever the deps change. Modelling status as a union is the key trade-off: it is slightly more verbose at the call site but eliminates the classic bug where data is read while loading is still true.
In Skeleton components, Skeleton renders a single shimmering block whose dimensions are passed as props, and SkeletonText composes several lines with the last line intentionally narrower to imitate a real paragraph. Keeping skeletons as dumb, presentational components means each screen can assemble a bespoke placeholder that matches its actual layout — a generic spinner cannot do that. The aria-hidden and role attributes keep the placeholders out of the accessibility tree while announcing a busy state.
In UserProfileCard, the hook and skeletons are wired together. The component switches on state.status: while loading, it renders a skeleton whose avatar circle and text lines echo the loaded card exactly, so nothing jumps when data arrives. The error branch offers a retry, and only the success branch touches state.data, which TypeScript now guarantees is present.
The main pitfall this pattern avoids is a flash of skeleton for near-instant responses; a short delay before showing the skeleton (or a minimum display time) can smooth that, though it is omitted here for clarity. Reach for skeletons on content-heavy views with predictable shapes — cards, lists, tables — and keep spinners for truly indeterminate actions like form submits.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"
export default class extends Controller {
connect() {
// Global shortcuts
Keyboard shortcuts with Stimulus and Mousetrap
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"
const application = Application.start()
application.debug = false
Disable submit button while Turbo form is submitting
Share this code
Here's the card — post it anywhere.