Zum Inhalt springen

React-Performance: Patterns, die wirklich etwas bewirken

Praktische Performance-Techniken für React: Memoisierung, Virtualisierung, Code-Splitting und Profiling mit echten Vorher-Nachher-Benchmarks.

3 Min. Lesezeit
Das React-Logo in der Mitte von Pfeilen, die sich zu sechs Mustern auffächern: Profil zuerst, chirurgische Memoization, Virtualisierung, Code-Splitting, Kollozierung und Übergänge.

Performance ist ein Feature

Langsame Oberflächen kosten Geld. Eine Verzögerung von 100ms bei der Ladezeit kann die Konversionsrate um 1% senken. Ein ruckelndes Scroll-Erlebnis vertreibt Nutzer. Performance ist kein nettes Extra — sie ist eine Produktanforderung.

Falsch angegangene Optimierung verschwendet jedoch Entwicklungszeit und erhöht die Komplexität, ohne einen messbaren Nutzen zu bringen. Dieser Leitfaden konzentriert sich auf Änderungen, die wirklich etwas bewirken.

Schritt 0: Profiling vor der Optimierung

Jede Performance-Verbesserung sollte mit einer Messung beginnen. Der React DevTools Profiler und der Performance-Tab in Chrome sind dabei deine besten Freunde.

tstypescript
// Enable profiling in production builds
// next.config.ts
const nextConfig = {
  experimental: {
    reactCompiler: true, // React 19+
  },
  productionBrowserSourceMaps: true,
};

Zeichne eine Performance-Trace auf, identifiziere die Komponenten mit den längsten Renderzeiten und behebe zuerst diese. Eine Komponente zu optimieren, die in 2ms rendert, ist verschwendete Mühe.

Pattern 1: Memoisierung — chirurgisch präzise einsetzen

React.memo, useMemo und useCallback haben ihren Preis: Die Vergleichsfunktion läuft bei jedem Rendern. Memoisierung lohnt sich nur, wenn die Komponente teuer UND die Props stabil sind.

tstypescript
// ❌ Over-memoizing cheap components — adds overhead, no benefit
const SimpleLabel = React.memo(({ text }: { text: string }) => (
  <span>{text}</span>
));
 
// ✅ Memoize expensive computations
const DataGrid = React.memo(({ rows, columns }: DataGridProps) => {
  return <VirtualizedTable rows={rows} columns={columns} />;
}, (prev, next) => {
  // Custom comparison — shallow equality isn't enough for arrays
  return prev.rows === next.rows && prev.columns === next.columns;
});
 
// ✅ Stabilize callbacks passed to memoized children
function Dashboard({ userId }: { userId: string }) {
  const handleRowClick = useCallback((rowId: string) => {
    router.push(`/items/${rowId}`);
  }, []); // No dependencies — stable across renders
 
  return <DataGrid rows={rows} onRowClick={handleRowClick} />;
}

Faustregel: Memoisiere nur, wenn du die Verbesserung messen kannst.

Pattern 2: Virtualisierung für lange Listen

10,000 DOM-Knoten zu rendern ist der schnellste Weg, die Performance zu ruinieren. Virtualisierung rendert nur die sichtbaren Elemente.

tstypescript
import { useVirtualizer } from "@tanstack/react-virtual";
 
function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
 
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 64, // estimated row height in px
    overscan: 5, // render 5 extra items above/below viewport
  });
 
  return (
    <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: "absolute",
              top: 0,
              transform: `translateY(${virtualItem.start}px)`,
              width: "100%",
              height: `${virtualItem.size}px`,
            }}
          >
            <ListRow item={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Bei 10,000 Elementen sinkt die Renderzeit von ~8 Sekunden auf ~50ms. Das ist keine marginale Verbesserung — das ist transformativ.

Pattern 3: Code-Splitting und Lazy Loading

Jedes Kilobyte JavaScript blockiert das Rendering. Teile dein Bundle an Routen- und Komponentengrenzen auf.

tstypescript
import { lazy, Suspense } from "react";
 
// Route-level splitting — Next.js handles this automatically
// Component-level splitting for heavy, conditionally rendered components
const RichTextEditor = lazy(() => import("@/components/RichTextEditor"));
const DataVisualization = lazy(() => import("@/components/DataVisualization"));
 
function ArticleEditor({ article }: { article: Article }) {
  const [showPreview, setShowPreview] = useState(false);
 
  return (
    <div>
      <Suspense fallback={<EditorSkeleton />}>
        <RichTextEditor content={article.content} />
      </Suspense>
 
      {showPreview && (
        <Suspense fallback={<div>Loading preview...</div>}>
          <DataVisualization data={article.metrics} />
        </Suspense>
      )}
    </div>
  );
}

In Kombination mit next/dynamic erhältst du eine sehr feingranulare Kontrolle darüber, was im initialen Bundle landet.

Pattern 4: State lokal halten

Globaler State verursacht globale Re-Renders. Die Lösung ist oft architektonischer Natur, nicht bloß ein Aufruf der React-API.

tstypescript
// ❌ Storing UI state in a global store — every subscriber re-renders
const useStore = create((set) => ({
  isDropdownOpen: false,
  toggleDropdown: () => set((s) => ({ isDropdownOpen: !s.isDropdownOpen })),
}));
 
// ✅ Keep UI state local to the component that owns it
function SearchBar() {
  const [isOpen, setIsOpen] = useState(false); // Only SearchBar re-renders
 
  return (
    <div>
      <input onFocus={() => setIsOpen(true)} onBlur={() => setIsOpen(false)} />
      {isOpen && <SearchSuggestions />}
    </div>
  );
}

Frage dich: „Wer muss diesen State eigentlich kennen?" Wenn die Antwort eine einzelne Komponente ist, halte ihn lokal.

Pattern 5: Transitions für nicht dringende Updates

Der Hook useTransition in React 18 markiert Updates als nicht dringend und hält die UI so auch bei teuren Rendervorgängen reaktionsfähig.

tstypescript
import { useTransition, useState } from "react";
 
function SearchPage() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Result[]>([]);
  const [isPending, startTransition] = useTransition();
 
  function handleSearch(value: string) {
    setQuery(value); // Urgent: update input immediately
 
    startTransition(() => {
      // Non-urgent: can be interrupted if user types again
      setResults(filterLargeDataset(value));
    });
  }
 
  return (
    <div>
      <input value={query} onChange={(e) => handleSearch(e.target.value)} />
      {isPending && <Spinner />}
      <ResultList results={results} />
    </div>
  );
}

Das Ergebnis: Das Eingabefeld bleibt flüssig, während die Ergebnisliste im Hintergrund berechnet wird.

Erfolg messen

Bevor du eine Optimierung ausrollst:

MetrikVorherNachherTool
Time to Interactive4.2s1.8sLighthouse
Largest Contentful Paint3.1s1.2sChrome DevTools
Renderzeit der Komponente320ms18msReact Profiler
Bundle-Größe (initial)840KB280KBnext build

Wenn du diese Tabelle nicht ausfüllen kannst, ist die Optimierung noch nicht abgeschlossen.

Die goldene Regel

Performance-Arbeit, die tatsächlich ausgeliefert wird, ist mehr wert als perfekte Arbeit, die es nie tut. Beginne mit den größten Gewinnen — Virtualisierung und Code-Splitting bringen typischerweise 10x-Verbesserungen. Memoisierung macht die letzten 10% aus.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX