Unnötige Re-Renders durch Context-Splitting vermeiden
Ein einzelner großer Context wirkt praktisch, bis jede Komponente bei jeder noch so kleinen Änderung neu rendert. Context-Splitting und echte Selektoren lösen das Problem strukturell statt symptomatisch.
Inhaltsverzeichnis
- 1. Das Problem: Ein Context, viele Consumer, jede Änderung trifft alle
- 2. Ein großer Context als Ausgangspunkt
- 3. Warum React nicht selektiv vergleicht
- 4. Context-Splitting als strukturelle Lösung
- 5. Memoization der Provider-Werte nicht vergessen
- 6. Consumer selbst memoisieren mit React.memo
- 7. Vergleich zu externen State-Libraries mit echten Selektoren
- 8. Wann Context-Splitting reicht und wann eine Library sinnvoll ist
- 9. Praxis-Checkliste für Context-Performance
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem: Ein Context, viele Consumer, jede Änderung trifft alle
React löst useContext nicht selektiv auf. Sobald sich der Wert, den ein Provider übergibt, ändert, rendert React jede Komponente neu, die diesen Context per useContext konsumiert, unabhängig davon, ob die Komponente den geänderten Teil des Werts überhaupt verwendet. Ein Context, der beispielsweise user, theme und notifications gemeinsam bündelt, sorgt dafür, dass eine Komponente, die nur theme liest, bei jeder neuen Benachrichtigung trotzdem neu rendert.
Das Muster fällt in kleinen Anwendungen kaum auf, wird aber mit wachsender Komponentenzahl schnell zum echten Performance-Problem. Besonders tückisch ist, dass der Effekt sich nicht in offensichtlichen Fehlern zeigt, sondern in diffuser Trägheit: Interaktionen fühlen sich langsamer an, die React DevTools Profiler-Ansicht zeigt einen Wald aus grau eingefärbten, unnötig gerenderten Komponenten, und niemand findet auf den ersten Blick die Ursache, weil der Code funktional korrekt ist.
2. Ein großer Context als Ausgangspunkt
Ein typisches Anti-Pattern ist der App-weite Context, der als zentrale Ablage für nahezu jeden globalen Zustand dient. Er ist bequem, weil man nur einen Provider einbindet und überall per useContext(AppContext) Zugriff hat. Genau diese Bequemlichkeit ist die Falle: Jede State-Änderung, egal wie klein, erzeugt ein neues Context-Objekt, und React kann nicht erkennen, dass ein Consumer nur an einem Teilbereich interessiert ist.
Im folgenden Beispiel sieht man den klassischen Aufbau. UserBadge liest ausschließlich user, wird aber bei jeder Änderung von notifications ebenfalls neu gerendert, weil beide Werte im selben Context-Objekt stecken und React am Objekt als Ganzes vergleicht, nicht an dessen einzelnen Feldern.
// AppContext.jsx -- ein Context fuer alles
const AppContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
const value = { user, setUser, theme, setTheme, notifications, setNotifications };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
// UserBadge.jsx -- rendert bei JEDER Aenderung von notifications neu
function UserBadge() {
const { user } = useContext(AppContext);
console.log('UserBadge rendert');
return <span>{user?.name}</span>;
}
3. Warum React nicht selektiv vergleicht
Der Grund liegt in der Funktionsweise von useContext: Der Hook abonniert das gesamte Context-Objekt, nicht einzelne Felder daraus. Ändert sich die Referenz des value-Objekts, das der Provider übergibt, muss React allen abonnierten Komponenten mitteilen, dass sich etwas geändert haben könnte, und sie neu rendern lassen. Ein flacher Vergleich einzelner Felder findet dabei nicht statt, weil der Context selbst keine Vorstellung davon hat, welche Teile seines Werts eine Komponente tatsächlich nutzt.
Das ist kein Bug, sondern bewusstes Design: Die Context API wurde ursprünglich für selten wechselnde Werte wie Theming oder Lokalisierung konzipiert, nicht als vollwertige State-Management-Lösung für häufig wechselnde, granulare Daten. Wird sie trotzdem so eingesetzt, muss man die fehlende Selektivität selbst kompensieren, entweder durch Architektur oder durch zusätzliche Bibliotheken.
4. Context-Splitting als strukturelle Lösung
Die direkteste Lösung ist, den einen großen Context in mehrere kleine, fachlich fokussierte Contexts aufzuteilen. Statt eines AppContext gibt es dann UserContext, ThemeContext und NotificationContext, jeweils mit eigenem Provider und eigenem value-Objekt. Eine Komponente, die nur UserContext konsumiert, wird dann ausschließlich bei Änderungen am Nutzerobjekt neu gerendert, Änderungen an Benachrichtigungen betreffen sie nicht mehr.
Wichtig ist, die Contexts nach Änderungshäufigkeit und fachlicher Zusammengehörigkeit zu trennen, nicht rein nach Datentyp. Werte, die immer gemeinsam geändert werden, dürfen im selben Context bleiben, denn zusätzliche Contexts bedeuten zusätzliche Provider-Verschachtelung und mehr kognitive Last beim Lesen des Baums. Das Ziel ist ein Gleichgewicht: so viele Contexts wie nötig für Renderisolation, so wenige wie möglich für Übersichtlichkeit.
// UserContext.jsx
const UserContext = createContext(null);
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
// NotificationContext.jsx -- eigener, unabhaengiger Context
const NotificationContext = createContext(null);
function NotificationProvider({ children }) {
const [notifications, setNotifications] = useState([]);
const value = useMemo(
() => ({ notifications, setNotifications }),
[notifications]
);
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
);
}
// UserBadge.jsx -- rendert jetzt NICHT mehr bei Notification-Aenderungen
function UserBadge() {
const { user } = useContext(UserContext);
return <span>{user?.name}</span>;
}
5. Memoization der Provider-Werte nicht vergessen
Context-Splitting allein reicht nicht, wenn das value-Objekt bei jedem Render des Providers neu erzeugt wird. Ein Literal wie { user, setUser } direkt im JSX erzeugt bei jedem Render eine neue Objektreferenz, selbst wenn user sich gar nicht geändert hat. React vergleicht Context-Werte per Referenzgleichheit, sodass Consumer trotzdem neu rendern, obwohl der Aufteilungsschritt an sich korrekt war.
Die Lösung ist useMemo um das value-Objekt, mit den tatsächlichen Abhängigkeiten in der Dependency-Liste. Erst dann bleibt die Referenz stabil, solange sich die referenzierten Werte nicht ändern, und Consumer, die den Context nutzen, rendern wirklich nur dann neu, wenn sich fachlich etwas geändert hat. Dieser Schritt wird in der Praxis häufig vergessen und macht viele gut gemeinte Context-Splits wirkungslos.
6. Consumer selbst memoisieren mit React.memo
Selbst mit sauber gesplitteten und memoisierten Contexts kann eine Elternkomponente unnötige Re-Renders an Kindkomponenten weitergeben, wenn diese nicht selbst memoisiert sind. React.memo verhindert, dass eine Kindkomponente neu rendert, solange sich ihre Props nicht ändern, und ergänzt damit die Context-Optimierung um die Komponentenebene.
Kombiniert man Context-Splitting, useMemo für Provider-Werte und React.memo für teure Kindkomponenten, entsteht ein Renderverhalten, das sich präzise an tatsächlichen Datenänderungen orientiert statt an der Struktur des Providerbaums. Wichtig ist, diese Optimierungen gezielt einzusetzen, nicht überall präventiv, denn React.memo hat selbst einen kleinen Vergleichs-Overhead, der sich bei trivialen Komponenten nicht lohnt.
const ExpensiveNotificationList = React.memo(function ExpensiveNotificationList({
notifications,
}) {
console.log('ExpensiveNotificationList rendert');
return (
<ul>
{notifications.map((n) => (
<li key={n.id}>{n.message}</li>
))}
</ul>
);
});
7. Vergleich zu externen State-Libraries mit echten Selektoren
Bibliotheken wie Zustand oder Jotai lösen das Selektivitätsproblem grundsätzlich anders: Sie erlauben es, gezielt Selektoren zu definieren, die nur den benötigten Teilbereich des Zustands abonnieren. Eine Komponente, die useStore((state) => state.user) aufruft, wird ausschließlich neu gerendert, wenn sich das Ergebnis dieser Selektorfunktion tatsächlich ändert, unabhängig davon, wie viele andere Felder im globalen Store sich parallel ändern.
Das ist ein grundlegend anderer Mechanismus als die Context API: Statt die gesamte Consumer-Baumstruktur bei jeder Wertänderung zu benachrichtigen, prüft die Bibliothek pro Selektor, ob sich der abgeleitete Wert geändert hat, und rendert nur dann. Für Anwendungen mit vielen, häufig wechselnden globalen Zuständen ist das oft die pragmatischere Lösung als immer feineres Context-Splitting, weil die Selektivität nicht durch manuelle Architekturarbeit erkauft werden muss, sondern eingebaut ist.
8. Wann Context-Splitting reicht und wann eine Library sinnvoll ist
Context-Splitting ist die richtige Wahl, wenn der globale Zustand überschaubar ist und sich klar in fachliche Bereiche trennen lässt, etwa Nutzer, Theme und Sprache. Es kommt ohne zusätzliche Abhängigkeit aus, bleibt nah am React-Standardwerkzeugkasten und ist für neue Teammitglieder leicht nachvollziehbar, weil keine zusätzliche API gelernt werden muss.
Sobald der Zustand komplex wird, viele voneinander unabhängige, häufig wechselnde Felder enthält oder plattformübergreifend außerhalb der React-Baumstruktur gelesen werden muss, etwa in Event-Handlern oder Utility-Funktionen, wird eine externe Library mit echten Selektoren attraktiver. Der Umstieg lohnt sich, sobald man merkt, dass man Contexts nur noch aus Performance-Gründen weiter aufsplittet, nicht mehr aus fachlicher Notwendigkeit, denn das ist ein Zeichen, dass die Context API an ihre strukturellen Grenzen stößt.
9. Praxis-Checkliste für Context-Performance
Bevor man an der Context-Struktur schraubt, lohnt sich ein Blick in den React DevTools Profiler: Ein Aufnahme-Durchlauf zeigt zuverlässig, welche Komponenten unnötig neu rendern und ob die Ursache tatsächlich ein Context ist. Ohne diese Messung besteht die Gefahr, Zeit in Optimierungen zu stecken, die keinen messbaren Effekt haben, während das eigentliche Problem woanders liegt, etwa in teuren Berechnungen ohne useMemo.
Die folgende Tabelle fasst die wichtigsten Techniken zusammen und ordnet sie nach Aufwand und Wirkung ein, damit man gezielt entscheiden kann, welcher Schritt für die eigene Codebasis als Nächstes sinnvoll ist.
| Technik | Aufwand | Wirkung | Wann einsetzen |
|---|---|---|---|
useMemo im Provider |
gering | verhindert unnötige Referenzänderungen | immer, sobald ein Context-Value ein Objekt ist |
| Context-Splitting | mittel | isoliert Re-Renders pro Fachbereich | wenn Consumer nur Teilbereiche nutzen |
React.memo auf Kindern |
gering | stoppt Weitergabe unveränderter Props | bei teuren Kindkomponenten |
| Externe Library mit Selektoren | hoch | feingranulare, eingebaute Selektivität | bei komplexem, häufig wechselndem globalem State |
Mironsoft
React-Architektur, Performance und Magento-Frontend-Integration
React-Frontends, die schnell bleiben statt mit jedem Feature langsamer zu werden?
Wir prüfen bestehende React-Anwendungen auf unnötige Re-Renders, aufgeblähte Bundles und fragile State-Verwaltung und bauen daraus ein Frontend, das performant bleibt und sich sauber an Magento oder andere Backends anbindet.
Performance-Audit
Re-Renders, Bundle-Größe und Ladezeiten systematisch messen und beheben.
State-Architektur
Context, Zustand und Server State sauber trennen statt alles in einen Topf zu werfen.
Magento-Integration
GraphQL- oder REST-Anbindung an Magento robust und typsicher aufbauen.
10. Zusammenfassung
Context-Splitting: Das Wichtigste auf einen Blick
Kernproblem
useContext abonniert das gesamte Objekt, nicht einzelne Felder, daher rendert jede Änderung alle Consumer neu.
Erste Maßnahme
Provider-Value mit useMemo stabilisieren, bevor überhaupt gesplittet wird.
Strukturlösung
Große Contexts in fachlich fokussierte, kleinere Contexts aufteilen.
Alternative
Bei komplexem State: Library mit echten Selektoren wie Zustand oder Jotai einsetzen.