2 min read
Web DevelopmentStreaming UI Patterns: Progressive Loading für moderne Web-Apps
Design Patterns für Streaming UI mit React Suspense. Skeleton Loading, Progressive Enhancement und optimale User Experience bei langsamen Daten.
Streaming UISuspenseSkeleton LoadingProgressive LoadingReact StreamingSSR Streaming

Streaming UI Patterns: Progressive Loading für moderne Web-Apps
Meta-Description: Design Patterns für Streaming UI mit React Suspense. Skeleton Loading, Progressive Enhancement und optimale User Experience bei langsamen Daten.
Keywords: Streaming UI, Suspense, Skeleton Loading, Progressive Loading, React Streaming, SSR Streaming, Loading States
Einführung
Streaming UI bedeutet: Zeige sofort was du hast, streame den Rest nach. Statt einer leeren Seite oder einem Spinner sieht der User sofort Inhalte – während langsame Daten im Hintergrund laden.
Das Streaming-Prinzip
┌─────────────────────────────────────────────────────────────┐
│ STREAMING VS. BLOCKING │
├─────────────────────────────────────────────────────────────┤
│ │
│ Blocking (Traditionell): │
│ ┌────────────────────────────────────────────────────┐ │
│ │ [Spinner 3s] → [Komplette Seite] │ │
│ └────────────────────────────────────────────────────┘ │
│ User wartet 3 Sekunden auf irgendetwas │
│ │
│ Streaming (Modern): │
│ ┌────────────────────────────────────────────────────┐ │
│ │ [Header] ─ sofort │ │
│ │ [Nav] ─ sofort │ │
│ │ [Hero] ─ 100ms │ │
│ │ [Stats] ─ [Skeleton] → [Daten] ─ 500ms │ │
│ │ [Chart] ─ [Skeleton] → [Daten] ─ 1s │ │
│ │ [Table] ─ [Skeleton] → [Daten] ─ 2s │ │
│ │ [Footer] ─ sofort │ │
│ └────────────────────────────────────────────────────┘ │
│ User sieht sofort Inhalte, Details laden progressiv │
│ │
└─────────────────────────────────────────────────────────────┘Pattern 1: Suspense Boundaries
Strategische Platzierung
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<main>
{/* Sofort gerendert - keine Suspense */}
<Header />
<Navigation />
{/* Schnelle Daten */}
<Suspense fallback={<StatsSkeleton />}>
<QuickStats />
</Suspense>
<div className="grid grid-cols-2 gap-4">
{/* Mittlere Latenz - unabhängig voneinander */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<TrafficChart />
</Suspense>
</div>
{/* Langsame Daten - lädt zuletzt */}
<Suspense fallback={<TableSkeleton rows={10} />}>
<DetailedReports />
</Suspense>
{/* Statisch */}
<Footer />
</main>
);
}Nested Suspense für feinere Kontrolle
function ProductSection() {
return (
<section>
<Suspense fallback={<ProductListSkeleton />}>
<ProductList />
{/* Nested: Reviews laden nach Produkten */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews />
</Suspense>
</Suspense>
</section>
);
}Pattern 2: Skeleton Components
Anatomy-preserving Skeletons
// components/skeletons/ProductCardSkeleton.tsx
export function ProductCardSkeleton() {
return (
<div className="border rounded-lg p-4 animate-pulse">
{/* Bild */}
<div className="h-48 bg-gray-200 rounded-lg mb-4" />
{/* Titel */}
<div className="h-6 bg-gray-200 rounded w-3/4 mb-2" />
{/* Beschreibung */}
<div className="space-y-2 mb-4">
<div className="h-4 bg-gray-200 rounded w-full" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
{/* Preis + Button */}
<div className="flex justify-between items-center">
<div className="h-8 bg-gray-200 rounded w-24" />
<div className="h-10 bg-gray-200 rounded w-28" />
</div>
</div>
);
}
// Grid Skeleton
export function ProductGridSkeleton({ count = 6 }) {
return (
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: count }).map((_, i) => (
<ProductCardSkeleton key={i} />
))}
</div>
);
}Shimmer Effect
/* styles/skeleton.css */
.skeleton-shimmer {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}// Skeleton mit Shimmer
export function ShimmerSkeleton({ className }: { className: string }) {
return <div className={`skeleton-shimmer ${className}`} />;
}Pattern 3: Optimistic Updates
Sofortige UI-Reaktion
'use client';
import { useOptimistic, useTransition } from 'react';
import { addToFavorites } from './actions';
function FavoriteButton({ productId, initialFavorited }) {
const [isPending, startTransition] = useTransition();
// Optimistic State
const [optimisticFavorited, addOptimistic] = useOptimistic(
initialFavorited,
(current, newValue: boolean) => newValue
);
const handleClick = () => {
// Sofort UI updaten
addOptimistic(!optimisticFavorited);
// Server-Action im Hintergrund
startTransition(async () => {
await addToFavorites(productId, !optimisticFavorited);
});
};
return (
<button
onClick={handleClick}
className={optimisticFavorited ? 'text-red-500' : 'text-gray-400'}
>
{optimisticFavorited ? '❤️' : '🤍'}
{isPending && <span className="ml-1">...</span>}
</button>
);
}Pattern 4: Loading Hierarchies
loading.tsx für Route-Level
// app/products/loading.tsx
export default function ProductsLoading() {
return (
<div>
{/* Header ist statisch */}
<div className="h-8 w-48 bg-gray-200 rounded mb-6" />
{/* Filter Bar Skeleton */}
<div className="flex gap-2 mb-4">
<div className="h-10 w-32 bg-gray-200 rounded" />
<div className="h-10 w-32 bg-gray-200 rounded" />
<div className="h-10 w-32 bg-gray-200 rounded" />
</div>
{/* Product Grid Skeleton */}
<ProductGridSkeleton count={9} />
</div>
);
}Hierarchische Loading States
// Layout mit persistentem Skeleton
export default function ProductsLayout({
children
}: {
children: React.ReactNode
}) {
return (
<div>
{/* Immer sichtbar */}
<ProductsHeader />
<FilterSidebar />
{/* Content lädt */}
<main className="flex-1">
{children}
</main>
</div>
);
}Pattern 5: Streaming mit LLM
AI-Response Streaming UI
'use client';
import { useStreamingChat } from '@/hooks/useStreamingChat';
function ChatInterface() {
const { response, isStreaming, sendMessage } = useStreamingChat();
const [input, setInput] = useState('');
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4">
{/* Streaming Response */}
{(response || isStreaming) && (
<div className="bg-gray-100 rounded-lg p-4">
{response}
{isStreaming && (
<span className="inline-block w-2 h-4 bg-blue-500 ml-1 animate-pulse" />
)}
</div>
)}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(input);
setInput('');
}}
className="p-4 border-t"
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isStreaming}
className="w-full p-2 border rounded"
/>
</form>
</div>
);
}Partial Content während Streaming
function AIAnalysisCard() {
const { analysis, isAnalyzing, sections } = useAIAnalysis();
return (
<div className="space-y-4">
{/* Sections erscheinen progressiv */}
{sections.map((section, i) => (
<div
key={i}
className={`transition-opacity duration-300 ${
section.loaded ? 'opacity-100' : 'opacity-0'
}`}
>
<h3>{section.title}</h3>
{section.loaded ? (
<p>{section.content}</p>
) : (
<div className="h-20 skeleton-shimmer rounded" />
)}
</div>
))}
{isAnalyzing && (
<div className="text-sm text-gray-500">
Analysiere... {sections.filter(s => s.loaded).length}/{sections.length}
</div>
)}
</div>
);
}Pattern 6: Error Boundaries mit Fallback
// components/ErrorBoundary.tsx
'use client';
import { useEffect } from 'react';
export function ErrorBoundary({
error,
reset
}: {
error: Error;
reset: () => void;
}) {
useEffect(() => {
console.error('Error:', error);
}, [error]);
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<h2 className="text-red-800 font-semibold">
Etwas ist schiefgelaufen
</h2>
<p className="text-red-600 text-sm mt-1">
{error.message}
</p>
<button
onClick={reset}
className="mt-3 px-4 py-2 bg-red-100 text-red-800 rounded hover:bg-red-200"
>
Erneut versuchen
</button>
</div>
);
}
// error.tsx für Route-Level
export default function ProductsError({
error,
reset
}: {
error: Error;
reset: () => void;
}) {
return <ErrorBoundary error={error} reset={reset} />;
}Performance-Metriken
| Metrik | Blocking | Streaming | Verbesserung |
|---|---|---|---|
| **FCP** | 3.2s | 0.8s | 75% schneller |
| **LCP** | 3.5s | 1.2s | 66% schneller |
| **TTI** | 4.0s | 2.0s | 50% schneller |
| **CLS** | 0.15 | 0.02 | 87% besser |
Best Practices
Do's
// ✅ Unabhängige Suspense Boundaries
<Suspense fallback={<A />}><ComponentA /></Suspense>
<Suspense fallback={<B />}><ComponentB /></Suspense>
// ✅ Skeleton passt zur echten Komponente
<Suspense fallback={<ProductCardSkeleton />}>
<ProductCard />
</Suspense>
// ✅ Static Content außerhalb von Suspense
<Header /> {/* Kein Suspense nötig */}
<Suspense>
<DynamicContent />
</Suspense>
<Footer /> {/* Kein Suspense nötig */}Don'ts
// ❌ Eine große Suspense für alles
<Suspense fallback={<FullPageSpinner />}>
<EntirePage />
</Suspense>
// ❌ Generischer Spinner statt Skeleton
<Suspense fallback={<Spinner />}>
<ComplexDashboard />
</Suspense>
// ❌ Suspense um statischen Content
<Suspense>
<StaticHeader />
</Suspense>Fazit
Streaming UI Patterns verbessern UX dramatisch:
- Sofortige Inhalte: User sieht nie eine leere Seite
- Progressive Loading: Wichtiges zuerst, Details später
- Perceived Performance: App fühlt sich schneller an
- Resilience: Langsame Teile blockieren nicht den Rest
Die Zukunft ist nicht "Laden oder Geladen" – es ist ein Spektrum.
Bildprompts
- "Website loading progressively in sections, waterfall effect, UI/UX concept"
- "Skeleton screens transforming into real content, animation sequence"
- "User happily browsing while content streams in background, modern web experience"