Add blog posts, cleanup unused files, update components
- Add 100 blog posts covering AI, development, and tech topics - Add .env.example for environment configuration - Add accessibility and lighthouse audit scripts - Remove obsolete SEO reports and temporary files - Remove dev-dist build artifacts and backup files - Remove unused portrait images (moved/consolidated elsewhere) - Update contact form and component improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
# 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
|
||||
|
||||
```tsx
|
||||
// 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
|
||||
|
||||
```tsx
|
||||
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
|
||||
|
||||
```tsx
|
||||
// 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
|
||||
|
||||
```css
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Skeleton mit Shimmer
|
||||
export function ShimmerSkeleton({ className }: { className: string }) {
|
||||
return <div className={`skeleton-shimmer ${className}`} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: Optimistic Updates
|
||||
|
||||
### Sofortige UI-Reaktion
|
||||
|
||||
```tsx
|
||||
'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
|
||||
|
||||
```tsx
|
||||
// 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
|
||||
|
||||
```tsx
|
||||
// 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
|
||||
|
||||
```tsx
|
||||
'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
|
||||
|
||||
```tsx
|
||||
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
|
||||
|
||||
```tsx
|
||||
// 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
|
||||
|
||||
```tsx
|
||||
// ✅ 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
|
||||
|
||||
```tsx
|
||||
// ❌ 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:
|
||||
|
||||
1. **Sofortige Inhalte**: User sieht nie eine leere Seite
|
||||
2. **Progressive Loading**: Wichtiges zuerst, Details später
|
||||
3. **Perceived Performance**: App fühlt sich schneller an
|
||||
4. **Resilience**: Langsame Teile blockieren nicht den Rest
|
||||
|
||||
Die Zukunft ist nicht "Laden oder Geladen" – es ist ein Spektrum.
|
||||
|
||||
---
|
||||
|
||||
## Bildprompts
|
||||
|
||||
1. "Website loading progressively in sections, waterfall effect, UI/UX concept"
|
||||
2. "Skeleton screens transforming into real content, animation sequence"
|
||||
3. "User happily browsing while content streams in background, modern web experience"
|
||||
|
||||
---
|
||||
|
||||
## Quellen
|
||||
|
||||
- [React Suspense Documentation](https://react.dev/reference/react/Suspense)
|
||||
- [Next.js Loading UI](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming)
|
||||
- [Vercel: Streaming SSR](https://vercel.com/blog/streaming-server-rendering-with-suspense)
|
||||
- [Web.dev: Skeleton Screens](https://web.dev/articles/skeleton-screens)
|
||||
Reference in New Issue
Block a user