Content that appears instantly feels fast.
Content that appears instantly feels fast. Stream when possible, load progressively.
For real-time text streaming (like ChatGPT):
function StreamingText({ stream }: { stream: ReadableStream<Uint8Array> }) {
const [content, setContent] = useState('');
const [isComplete, setIsComplete] = useState(false);
useEffect(() => {
const reader = stream.getReader();
const decoder = new TextDecoder();
async function read() {
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
setIsComplete(true);
break;
}
const text = decoder.decode(value, { stream: true });
setContent(prev => prev + text);
}
} catch (error) {
console.error('Stream error:', error);
setIsComplete(true);
}
}
read();
return () => {
reader.cancel();
};
}, [stream]);
return (
<div className="streaming-content">
{content}
{!isComplete && <span className="cursor-blink">▊</span>}
</div>
);
}
.cursor-blink {
animation: blink 1s ease-in-out infinite;
font-family: 'Zen Mono', monospace;
}
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
Skeleton screens are better than spinners for predictable layouts.
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.skeleton {
background: linear-gradient(
90deg,
#1a1a1a 25%,
#2a2a2a 50%,
#1a1a1a 75%
);
}
}
Usage:
function CardSkeleton() {
return (
<div className="card">
<div className="skeleton" style={{ height: '200px', marginBottom: '1rem' }} />
<div className="skeleton" style={{ height: '1.5rem', width: '60%', marginBottom: '0.5rem' }} />
<div className="skeleton" style={{ height: '1rem', width: '100%' }} />
<div className="skeleton" style={{ height: '1rem', width: '80%' }} />
</div>
);
}
Load critical content first, defer below-the-fold:
function Page() {
const [aboveFold, setAboveFold] = useState(null);
const [belowFold, setBelowFold] = useState(null);
useEffect(() => {
// Load critical content immediately
fetchAboveFold().then(setAboveFold);
// Delay below-fold content
setTimeout(() => {
fetchBelowFold().then(setBelowFold);
}, 100);
}, []);
return (
<>
{aboveFold ? <AboveFold data={aboveFold} /> : <Skeleton />}
{belowFold ? <BelowFold data={belowFold} /> : null}
</>
);
}
Assume success, rollback on error:
function TodoList() {
const [items, setItems] = useState<Todo[]>([]);
const addItem = async (newItem: Omit<Todo, 'id'>) => {
// Create temporary item
const tempItem = { ...newItem, id: 'temp', pending: true };
// Add immediately (optimistic)
setItems(prev => [...prev, tempItem]);
try {
// Save to server
const saved = await api.saveTodo(newItem);
// Replace temp with real
setItems(prev => prev.map(item =>
item.id === 'temp' ? saved : item
));
} catch (error) {
// Rollback on error
setItems(prev => prev.filter(item => item.id !== 'temp'));
toast.error('Failed to add item');
}
};
return (
<div>
{items.map(item => (
<TodoItem
key={item.id}
item={item}
isPending={item.pending}
/>
))}
</div>
);
}
/* Elegant spinner */
.spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(0, 0, 0, 0.1);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Dots loader (more subtle) */
.dots-loader {
display: flex;
gap: 4px;
}
.dot {
width: 6px;
height: 6px;
background: var(--color-primary);
border-radius: 50%;
animation: bounce 1.4s ease-in-out infinite;
}
.dot:nth-child(1) { animation-delay: 0s; }
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
.progress-bar {
width: 100%;
height: 4px;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-primary);
transition: width 0.3s ease;
}
/* Indeterminate state */
.progress-fill.indeterminate {
width: 30%;
animation: indeterminate 1.5s ease-in-out infinite;
}
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
Choose based on operation duration:
function useLoadingIndicator(duration: number) {
if (duration < 100) return null;
if (duration < 1000) return <Spinner />;
if (duration < 5000) return <Skeleton />;
return <ProgressBar withCancel />;
}
For server-to-client streaming:
function useSSE(url: string) {
const [data, setData] = useState<string[]>([]);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
setData(prev => [...prev, event.data]);
};
eventSource.onerror = (err) => {
setError(new Error('SSE connection error'));
eventSource.close();
};
return () => {
eventSource.close();
};
}, [url]);
return { data, error };
}
For bidirectional real-time communication:
function useWebSocket(url: string) {
const [messages, setMessages] = useState<string[]>([]);
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => setIsConnected(true);
ws.onclose = () => setIsConnected(false);
ws.onmessage = (event) => {
setMessages(prev => [...prev, event.data]);
};
return () => {
ws.close();
};
}, [url]);
const send = (message: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(message);
}
};
return { messages, isConnected, send };
}