Unveränderliche Datenstrukturen richtig einsetzen
Mutabler State ist die häufigste Quelle schwer findbarer Bugs in JavaScript-Anwendungen. Immutability Patterns machen Zustandsänderungen explizit, ermöglichen Änderungserkennung per Referenzvergleich und verhindern, dass shared State an unerwarteten Stellen verändert wird – von Object.freeze bis zu Records und Tuples.
Inhaltsverzeichnis
- 1. Warum mutabler State Bugs produziert
- 2. Object.freeze: Shallow vs. Deep Immutability
- 3. Spread-Operator und Object.assign als Immutability-Werkzeuge
- 4. Immutable Array-Operationen ohne Mutation
- 5. Strukturelles Sharing: Effizienz ohne Kopieren
- 6. Immer.js: Mutable schreiben, immutable erhalten
- 7. Referenzgleichheit und Change Detection
- 8. Records und Tuples: native Immutability (Stage 2)
- 9. Immutability-Strategien im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum mutabler State Bugs produziert
JavaScript-Objekte und Arrays werden als Referenz übergeben. Wenn man ein Objekt an eine Funktion übergibt, erhält die Funktion nicht eine Kopie, sondern einen Zeiger auf dasselbe Objekt im Speicher. Jede Mutation innerhalb der Funktion verändert das Original – auch wenn das der Aufrufer nicht erwartet. Diese unerwünschten Nebeneffekte sind eine der häufigsten Quellen schwer reproduzierbarer Bugs: der State wird an einer Stelle verändert, die Auswirkung zeigt sich aber an einer ganz anderen Stelle der Anwendung, oft viel später.
Immutability Patterns lösen dieses Problem fundamental: anstatt Objekte zu verändern, werden neue Objekte mit den geänderten Werten erzeugt. Das Original bleibt unangetastet. In React und Redux ist das nicht nur eine Empfehlung, sondern eine Grundvoraussetzung für die Funktionsfähigkeit der Bibliothek: die Change Detection basiert auf Referenzvergleich (===), nicht auf tiefem Wertevergleich. Mutiert man den State direkt, zeigt der Vergleich keine Änderung – die UI wird nicht aktualisiert. Immutability macht Zustandsänderungen explizit, vorhersehbar und testbar.
2. Object.freeze: Shallow vs. Deep Immutability
Object.freeze() ist das einfachste Immutability-Werkzeug in JavaScript. Es verhindert, dass neue Eigenschaften hinzugefügt, bestehende verändert oder gelöscht werden können. Im Strict Mode wirft ein Versuch, ein gefrorenes Objekt zu verändern, einen TypeError. Außerhalb des Strict Mode schlägt die Mutation lautlos fehl – ein typischer Fallstrick. Das ist der Grund, warum TypeScript mit Strict Mode und Linting-Regeln wie prefer-const empfohlen werden.
Der kritische Schwachpunkt von Object.freeze(): es ist shallow. Verschachtelte Objekte und Arrays sind nicht eingefroren – nur ihre Referenzen im äußeren Objekt sind unveränderlich, nicht die referenzierten Objekte selbst. Für tiefe Immutability muss man rekursiv freezeën – das ist teuer für große Objekte und kein produktionstaugliches Pattern für häufige Zustandsänderungen. Die pragmatische Lösung: Deep Freeze für Konfigurationsobjekte, die nur einmal gesetzt werden, und für Unit-Tests, um mutierenden Code zu entdecken.
// Shallow freeze — nested objects remain mutable
const config = Object.freeze({
apiUrl: 'https://api.mironsoft.de',
timeout: 5000,
nested: { retries: 3 }, // NOT frozen
});
config.apiUrl = 'other'; // TypeError in strict mode, silently fails otherwise
config.nested.retries = 99; // Works! Shallow freeze doesn't protect nested objects
// Deep freeze utility — recursive, suitable for config objects
function deepFreeze(obj) {
if (obj === null || typeof obj !== 'object') return obj;
Object.getOwnPropertyNames(obj).forEach(name => {
deepFreeze(obj[name]);
});
return Object.freeze(obj);
}
const frozenConfig = deepFreeze({
db: { host: 'localhost', port: 5432 },
cache: { ttl: 300, maxSize: 1000 },
});
frozenConfig.db.host = 'other'; // TypeError — deep freeze protects nested objects
// Detect frozen objects
console.log(Object.isFrozen(frozenConfig)); // true
console.log(Object.isFrozen(frozenConfig.db)); // true
3. Spread-Operator und Object.assign als Immutability-Werkzeuge
Der Spread-Operator (...) ist das meistverwendete Immutability-Werkzeug in modernem JavaScript. { ...original, key: newValue } erstellt ein neues Objekt mit allen Eigenschaften von original, wobei key überschrieben wird. Das Original bleibt unverändert. Diese Syntax ist für flache Updates elegant und verständlich – für Objekte mit zwei oder drei Ebenen Tiefe ist sie aber bereits unhandlich, weil jede Ebene einzeln gespreaded werden muss.
Ein häufiger Fehler beim Spread: er ist ebenfalls shallow. const copy = { ...original } kopiert nur die oberste Ebene. Nested Objects teilen sich immer noch dieselbe Referenz. Wer glaubt, er habe eine tiefe Kopie erstellt, und dann das kopierte Nested-Objekt verändert, mutiert das Original. Das Pattern für immutable Updates auf mehreren Ebenen kombiniert mehrere Spreads: { ...state, user: { ...state.user, name: newName } }. Das ist korrekt, wird aber schnell unleserlich bei tiefer Verschachtelung – der Punkt, an dem Immer.js oder eine Hilfsfunktion wie produce sinnvoll wird.
// Immutable update patterns with spread operator
const state = {
user: { name: 'Max', age: 30, address: { city: 'Berlin', zip: '10115' } },
settings: { theme: 'dark', lang: 'de' },
cart: [{ id: 1, qty: 2 }, { id: 2, qty: 1 }],
};
// Flat update — safe and readable
const withNewTheme = { ...state, settings: { ...state.settings, theme: 'light' } };
// Deep update — correct but verbose
const withNewCity = {
...state,
user: {
...state.user,
address: {
...state.user.address,
city: 'Hamburg',
},
},
};
// Array updates without mutation
const addItem = { ...state, cart: [...state.cart, { id: 3, qty: 1 }] };
const removeItem = { ...state, cart: state.cart.filter(i => i.id !== 1) };
const updateQty = {
...state,
cart: state.cart.map(i => i.id === 2 ? { ...i, qty: i.qty + 1 } : i),
};
// Verify originals are untouched
console.log(state.settings.theme); // 'dark' — original unchanged
console.log(state.user.address.city); // 'Berlin' — original unchanged
4. Immutable Array-Operationen ohne Mutation
JavaScript-Arrays haben mutierbare Methoden (push, pop, splice, sort, reverse) und nicht-mutierbare Methoden (map, filter, reduce, concat, slice). Das Immutability-Pattern: ausschließlich die nicht-mutierbaren Methoden verwenden und für Operationen wie Sort und Reverse zunächst eine Kopie erstellen. Seit ES2023 gibt es die Methoden toSorted(), toReversed(), toSpliced() und with(), die neue Arrays zurückgeben statt das Original zu verändern.
Der häufigste Immutability-Bug bei Arrays: array.sort() sortiert das Original in-place und gibt es zurück. Wer das Ergebnis als neue Variable speichert, hat trotzdem das Original verändert, weil beide Variablen auf dasselbe Array zeigen. Das korrekte Pattern ist [...array].sort(compareFn) oder mit ES2023 array.toSorted(compareFn). Dasselbe gilt für reverse(): immer [...array].reverse() oder array.toReversed() verwenden, nie direktes array.reverse() auf shared State.
5. Strukturelles Sharing: Effizienz ohne Kopieren
Immutability klingt teuer: bei jeder Änderung ein neues Objekt erzeugen. Für kleine Objekte ist das vernachlässigbar. Aber bei einem Redux-Store mit hundert verschachtelten Objekten wäre ein vollständiges Deep-Clone bei jeder Aktion inakzeptabel. Die Lösung ist strukturelles Sharing: unveränderte Teile des Objekt-Baums werden nicht kopiert, sondern die Referenzen werden geteilt. Nur der Pfad von der Wurzel zur geänderten Eigenschaft wird neu erstellt.
Das ist genau das, was der Spread-Operator implizit tut: { ...state, user: { ...state.user, name: 'neu' } } erstellt neue Objekte für state und state.user, aber state.settings und state.cart werden nicht kopiert – die neuen und alten Objekte teilen sich dieselben Referenzen für unveränderliche Teile. Diese geteilten Referenzen ermöglichen den schnellen Referenzvergleich: wenn newState.settings === oldState.settings, weiß eine React-Komponente, dass sich die Settings nicht geändert haben, ohne einen tiefen Wertevergleich durchführen zu müssen.
// Structural sharing — unchanged parts share references
const before = {
users: [{ id: 1, name: 'Max' }, { id: 2, name: 'Anna' }],
settings: { theme: 'dark' },
metadata: { version: 3, lastUpdated: '2026-05-10' },
};
// Update only one user — settings and metadata are shared by reference
const after = {
...before,
users: before.users.map(u => u.id === 1 ? { ...u, name: 'Maximilian' } : u),
};
// Verify structural sharing — identical references for unchanged subtrees
console.log(after.settings === before.settings); // true — shared
console.log(after.metadata === before.metadata); // true — shared
console.log(after.users === before.users); // false — new array
console.log(after.users[1] === before.users[1]); // true — user 2 unchanged, shared
// Change detection in O(1) — no deep comparison needed
function hasSettingsChanged(prev, next) {
return prev.settings !== next.settings; // Reference equality suffices
}
console.log(hasSettingsChanged(before, after)); // false — same reference
6. Immer.js: Mutable schreiben, immutable erhalten
Immer.js löst das Lesbarkeits-Problem von tief verschachtelten Immutability-Updates elegant: man schreibt Code, der aussieht wie mutable Mutation, und Immer sorgt dafür, dass das Ergebnis ein neues, immutables Objekt ist. Die Funktion produce(baseState, recipe) übergibt der Recipe-Funktion einen Proxy (draft), der alle Mutationen aufzeichnet, ohne das Original zu berühren. Nach dem Ende der Recipe-Funktion berechnet Immer das neue immutable Objekt mit strukturellem Sharing.
Immer ist besonders wertvoll in Redux Toolkit, das produce intern in allen Reducer-Funktionen verwendet. Entwickler schreiben state.user.name = 'neu' statt { ...state, user: { ...state.user, name: 'neu' } }. Das macht komplexe State-Updates lesbar und fehlerfrei – Immer garantiert dabei volle Immutability des originalen State-Objekts. Der Performance-Overhead von Immer gegenüber manuellen Spreads ist bei typischen UI-State-Updates vernachlässigbar; bei sehr großen Objekten kann ein gezielter Vergleich sinnvoll sein.
7. Referenzgleichheit und Change Detection
Das wichtigste Performance-Feature von konsequenter Immutability ist schnelle Change Detection per Referenzvergleich. React.memo, useMemo, useCallback und React.PureComponent prüfen alle, ob Props oder Werte mit === gleich geblieben sind. Bei mutierten Objekten ist === immer true (dieselbe Referenz), auch wenn sich der Inhalt geändert hat – die Komponente wird nicht neu gerendert. Bei immutablen Updates ist === immer false für geänderte Objekte und true für unveränderte – exakt das, was React für korrekte, effiziente Re-Renders braucht.
Dieser Mechanismus macht Immutability zur Grundlage effizienter UI-Frameworks. Redux' connect(), Zustand's Selektoren, Recoil's Atoms – alle basieren auf der Annahme, dass unveränderter State dieselbe Referenz behält. Das ermöglicht Memoization, die tatsächlich funktioniert. Ein Selector, der mit useMemo memoized wird, muss seinen Wert nur dann neu berechnen, wenn seine Input-Referenzen sich geändert haben – das ist eine O(1)-Prüfung statt eines tiefen Wertevergleichs.
8. Records und Tuples: native Immutability (Stage 2)
Records und Tuples sind ein TC39-Proposal auf Stage 2, das native primitive Immutability in JavaScript einführt. Ein Record (mit #{ }-Syntax) ist wie ein Objekt, aber primitiv und tief immutabel – wie ein String oder eine Zahl. Zwei Records mit denselben Werten sind mit === gleich, ohne dass man eine Identitätsverwaltung braucht. Das löst ein Grundproblem von Objekt-basierter Immutability: zwei verschiedene Objekte mit identischem Inhalt sind immer !==, was Memoization und Change Detection erschwert.
Records und Tuples (mit #[ ]-Syntax) können keine normalen Objekte enthalten – alle Werte müssen primitiv oder selbst Records/Tuples sein. Das garantiert tiefe Immutability ohne Laufzeitchecks. In Maps und Sets als Keys können Records und Tuples verwendet werden – ein Feature, das mit normalen Objekten nicht möglich ist. Obwohl noch nicht in den Browsern verfügbar, signalisiert das Proposal die Richtung der JavaScript-Plattform in Bezug auf erstklassige Unterstützung für unveränderliche Datenstrukturen.
9. Immutability-Strategien im Vergleich
Die richtige Immutability-Strategie hängt vom Anwendungsfall ab: flache vs. tiefe Strukturen, Schreibhäufigkeit, Team-Erfahrung und Performance-Anforderungen. Es gibt keine universelle Antwort, aber klare Empfehlungen je nach Kontext.
| Strategie | Stärken | Schwächen | Ideal für |
|---|---|---|---|
| Object.freeze | Kein Overhead, nativ | Nur shallow, Fehler nur im Strict Mode | Konfigurationsobjekte, Tests |
| Spread-Operator | Lesbar, kein Overhead | Verbose bei tiefer Verschachtelung | Flache Updates, React-State |
| Immer.js | Lesbar, tiefe Updates einfach | Externe Abhängigkeit, Proxy-Overhead | Redux-Reducer, komplexer State |
| structuredClone | Native Deep Copy, kein Sharing | O(n) – kein strukturelles Sharing | Kleine Objekte, einmaliges Kopieren |
| Records & Tuples | Wertgleichheit mit ===, primitiv | Noch nicht verfügbar (Stage 2) | Zukunft: Keys in Maps, Memoization |
Die pragmatische Empfehlung für die meisten Projekte: Spread-Operator für flache Updates, Immer.js (via Redux Toolkit) für komplexen State mit tiefer Verschachtelung, Object.freeze für Konfigurationen und Tests. structuredClone ist nützlich, wenn man wirklich eine unabhängige Kopie ohne jegliches Sharing braucht – etwa um einen State-Snapshot für Undo-Funktionalität zu erstellen. Das Immutability-Pattern am wenigsten oft unterschätzt: die Array-Methoden toSorted(), toReversed() und with(), die seit ES2023 in allen modernen Umgebungen verfügbar sind.
Mironsoft
JavaScript-Architektur, State-Management und Frontend-Performance
Stabiles State-Management für Ihre React-Anwendung?
Wir analysieren vorhandene State-Management-Implementierungen auf Mutationsfehler und bauen Immutability-Patterns ein, die Re-Render-Verhalten, Change Detection und Testbarkeit verbessern.
State-Audit
Analyse bestehender Redux/Zustand-Stores auf Mutationsfehler und falsche Change-Detection
Reducer-Refactoring
Migration von manuellen Spread-Patterns zu Immer.js / Redux Toolkit mit strukturellem Sharing
Performance-Optimierung
Memoization und Change Detection korrekt einrichten für messbar weniger Re-Renders
10. Zusammenfassung
Immutability Patterns sind keine optionale Best Practice – sie sind die Grundlage für korrekte Change Detection, zuverlässige Memoization und vorhersehbares State-Management in modernen JavaScript-Anwendungen. Object.freeze bietet einfache shallow Immutability für Konfigurationsobjekte. Der Spread-Operator ist das meistverwendete Tool für flache immutable Updates. Strukturelles Sharing stellt sicher, dass Immutability nicht auf Kosten der Performance geht – unveränderte Objekte teilen ihre Referenzen. Immer.js macht tiefe Updates lesbar ohne die Vorteile von Immutability aufzugeben.
Der wichtigste Perspektivwechsel: Immutability ist kein Performance-Problem, sondern eine Performance-Lösung. React.memo und useMemo funktionieren korrekt nur mit immutablen Updates. Arrays mit toSorted(), toReversed() und with() machen mutierende Array-Methoden obsolet. Records und Tuples werden Wertgleichheit mit === für Objekte ermöglichen – ein Paradigmenwechsel für State Management und Memoization in JavaScript.
Immutability Patterns — Das Wichtigste auf einen Blick
Fehler durch Mutation vermeiden
Objekte als Referenz übergeben und mutieren verursacht schwer findbare Bugs. Spread-Operator und Immer.js erzeugen neue Objekte, ohne das Original zu berühren.
Strukturelles Sharing
Unveränderte Teile des Objektbaums teilen Referenzen. O(1) Change Detection mit === – keine Deep-Comparison nötig für React, Redux und Memoization.
Array-Immutability ES2023
toSorted(), toReversed(), toSpliced() und with() geben neue Arrays zurück. Nie direkt sort() oder reverse() auf shared State anwenden.
Immer.js für komplexen State
produce(state, draft => { draft.x.y = z }) – mutable Syntax, immutables Ergebnis. Kernstück von Redux Toolkit, vollständiges strukturelles Sharing.
11. FAQ: JavaScript Immutability Patterns
1Warum ist Immutability in React wichtig?
2Object.freeze vs. Spread-Operator?
3Ist Immutability nicht teuer?
4Wann Immer.js statt Spread?
5Was ist strukturelles Sharing?
6const vs. Object.freeze?
7Array.sort() Mutationsfalle vermeiden?
[...array].sort(fn) oder ES2023 array.toSorted(fn) – immer neues Array, kein Mutieren des Originals.