Menu
Nazad na Blog
2 min read
Web Development

Streaming 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

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

MetrikBlockingStreamingVerbesserung
**FCP**3.2s0.8s75% schneller
**LCP**3.5s1.2s66% schneller
**TTI**4.0s2.0s50% schneller
**CLS**0.150.0287% 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:

  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