A QUICK SUMMARY – FOR THE BUSY ONES
TABLE OF CONTENTS
Seit Beginn der weit verbreiteten Verwendung von React war die De-facto-Standardbibliothek für die Zustandsverwaltung Redux. Es war fast ein Synonym für React — wann immer Sie etwas brauchten, das über den einfachsten Zustand hinausging, haben Sie nach Redux gegriffen. Der globale Status der Anwendung, die Logik zum Abrufen von Daten und sogar Formulare wurden in Redux verarbeitet. Es wurde oft für die großen Mengen an Standardformaten kritisiert, aber unabhängig davon war Redux mit seinem Speicherordner, seinen Aktionen, Reduzierern, Selektoren und Thunks fast allgegenwärtig.
Im Laufe der Jahre wurden andere Lösungen für die gängigsten Redux-Muster eingeführt. SSR-Frameworks und React-Abfrage hat den Datenabruf übernommen, Formik und React-Hook-Formular sind jetzt die wichtigsten Methoden, um mit Formularen umzugehen, und React-Hooks und Context sind die bevorzugte Lösung für den globalen Status. Der Anwendungsfall für einen globalen Staat wie Redux hat langsam abgenommen, aber es gibt immer noch einige Fälle, in denen ein zentralisierter Datenspeicher mit fein abgestuften Abonnements praktisch wäre. Redux gibt es immer noch (die Standardfrage, die mit behandelt wird) Redux-Werkzeugsatz), aber die Nische für seine Verwendung ist erheblich geschrumpft. Es hat auch seine eigenen, moderneren Konkurrenten, in Bibliotheken wie Condition oder Jotai.
In den letzten Jahren haben wir mit verschiedenen Zustandsverwaltungsmustern in mehreren großen React-Anwendungen experimentiert. Unser Ziel war es, Lösungen zu finden, die dem Code Struktur verleihen und die ergonomischen Vorteile der Trennung der Zustandslogik von der Benutzeroberfläche in den React-Komponenten hervorheben. Die Muster, die wir besprechen werden, haben sich nicht nur technisch gut skalieren lassen, sondern auch leicht von neuen Teammitgliedern übernommen werden und haben den kognitiven Aufwand, der typischerweise mit der Zustandsverwaltung verbunden ist, erheblich reduziert.
Redux ist zwar seit langem die De-facto-Lösung für das React-State-Management, aber unsere Erfahrung hat gezeigt, dass die Flexibilität und Einfachheit von Zustand erhebliche Vorteile bieten können, insbesondere wenn es um komplexe Zustandsinteraktionen geht. In diesem Artikel werden wir diese Muster im Detail untersuchen und zeigen, wie sie uns dabei geholfen haben, wartbarere Anwendungen zu entwickeln.
Bevor Sie sich mit Zustands-spezifischen Mustern befassen, lohnt es sich zu untersuchen, was die Architektur von Redux so überzeugend gemacht hat und welche Teile davon es wert sind, erhalten zu werden. Die ereignisbasierte Architektur, bei der Redux Pionierarbeit geleistet hat, bleibt auch bei der Umstellung auf modernere Tools wertvoll.
Es gibt viele verschiedene Ansätze für ereignisbasierte Zustandsverwaltungssysteme. Einige übernehmen ihre Terminologie von Event Sourcing oder CQRS, andere entwickeln ihre eigene. Das Hauptelement dieser Architektur ist ein Veranstaltung, was eine Beschreibung von etwas ist, das geschehen ist oder geschehen wird.
Sobald ein Ereignis bearbeitet werden soll, wird ein Minderer aufgerufen wird. Dabei handelt es sich um eine Funktion, die die Zustandsänderung als Reaktion auf ein Ereignis beschreibt. Reducer nehmen den vorherigen Zustand und das Ereignis und geben einen neuen Zustand zurück. Reducer sind ebenfalls rein, d. h. sie verursachen keine Nebenwirkungen wie API-Anfragen oder das Auslösen von Ausnahmen. Ob es einen Reducer für ein oder mehrere Ereignisse gibt, ist ein Implementierungsdetail. Bei Zustand gibt es normalerweise einen Reducer pro Ereignis, aber andere Systeme (wie Redux) haben möglicherweise einen Reducer, der mehrere Ereignisse behandelt.
Ereignisse werden erstellt von Aktionen das sind Funktionen, die von der Ansicht aus aufgerufen werden und entscheiden, welche Ereignisse erstellt und gesendet werden sollen, um von Reducern verarbeitet zu werden. Der Unterschied zwischen Aktionen und Reduzierern besteht darin, dass Aktionen nicht rein sein müssen. Eine Aktion kann eine beliebige Anzahl von Ereignissen auslösen, bis hin zu Null, wenn beispielsweise irgendeine Art von Validierung fehlschlägt. Anders ausgedrückt: Aktionen entscheiden, welche Ereignisse ausgelöst werden, während Reducer festlegen, was mit dem Staat passieren soll, sobald ein Ereignis ausgelöst wurde.
Eventbasierte Systeme sind mit weiteren Komplexitäten verbunden — z. B. Bedenken hinsichtlich des Zeitpunkts, Aktionen, die Ereignisse in der Zukunft auslösen usw. —, aber diese Art von Komplexität tritt selten, wenn überhaupt, auf, wenn sie zur Zustandsverwaltung in Frontend-Anwendungen verwendet werden. Die Terminologie variiert zwischen den vorhandenen Lösungen (z. B. werden in Redux die oben als Ereignisse und Aktionen beschriebenen Konzepte als Aktionen bzw. Aktionsersteller bezeichnet), aber diese Architektur ist für alle ziemlich universell.
Zustand ist eine einfache Zustandsverwaltungslösung, die hauptsächlich in React verwendet wird, obwohl sie auch außerhalb von React verwendet werden kann. Der naheliegendste Vergleichspunkt ist zwar Redux, aber Zustand behält viele der architektonischen Vorteile bei und macht gleichzeitig einen Großteil der Zeremonie überflüssig. Es bietet eine minimale API-Oberfläche, auf der Sie aufbauen können, um jede beliebige Strukturebene zu erstellen, die Ihre Anwendung benötigt. Es eignet sich hervorragend für kundenorientierte Anwendungen, wenn die integrierten Zustandsprimitive wie Bundesstaat verwenden
, Verwenden Sie Reducer
und Kontext verwenden
schneide es nicht.
Schauen wir uns ein Beispiel für einen Zustand-Shop an:
const useStore = create((set) => ({
widgets: 0,
gizmos: 0,
increase: () => {
set((state) => ({
widgets: state.widgets + 1,
}))
set((state) => ({
gizmos: state.gizmos + 3,
}))
},
}))
function App() {
const widgets = useStore((s) => s.widgets)
const gizmos = useStore((s) => s.gizmos)
const increase = useStore((s) => s.increase)
return (
<>
<button onClick={increase}>Increase</button>
Widgets: {widgets}, Gizmos: {gizmos}
</>
)
}
Hier sind einige interessante Funktionen von Zustand zu sehen. Der Staat und die Hinweise auf Aktionen befinden sich tatsächlich am selben Ort. Die Grenze zwischen Aktionen, Ereignissen und Reduzierern ist ziemlich verschwommen — es ist nicht wie bei den Aktionserstellern und -reduzierern von Redux, die oft in separate Dateien aufgeteilt werden, hier ist alles zusammen (obwohl es möglich ist, es zu ändern). Mit Zustand erhalten wir so ziemlich alles, was wir von einer Zustandsverwaltungslösung benötigen, in einem hübschen kleinen Paket.
Leider gibt es auch ziemlich viele Überschneidungen. Jeder einstellen
Der Funktionsaufruf sieht ziemlich ähnlich aus und hat die Form:
set(state => ({ ...state, /* something */ }))
Außerdem sollten die Zustandsänderungen unveränderlich sein. Leider macht JS das ziemlich schwierig, weil wir entweder präventiv den gesamten Zustand tief klonen und unsere Änderungen vornehmen können (was ineffizient ist) oder wir können eine Menge Spread-Operatoren verwenden, um jedes Level flach zu klonen, bis wir die gewünschte Tiefe erreicht haben (die sehr ausführlich ist). Zustand empfiehlt die Verwendung einer Unveränderbarkeitslösung wie Immer oder Objektive, um dieses Problem zu beheben.
Betrachtet man die erhöhen
Funktion wieder:
increase: () => {
set((state) => ({
widgets: state.widgets + 1,
}))
set((state) => ({
gizmos: state.gizmos + 3,
}))
},
Wir können die Elemente der Architektur identifizieren, die zuvor hier beschrieben wurden. erhöhen
ist eine Aktion und die Funktionen werden übergeben an einstellen
sind Reduktoren. Die Ereignisse sind in diesem Fall anonym: jeweils einstellen
call erzeugt ein neues Ereignis, und das Ereignis wird von der Funktion verarbeitet.
Wenn wir Redux Devtools verwenden, um den Zustandsstatus zu überprüfen, können wir Eventnamen und Payloads hinzufügen, indem wir den dritten Parameter an übergeben einstellen
, dies dient jedoch nur zu Entwicklungszwecken. (Der zweite Parameter gibt an, ob der Status ersetzt oder nur oberflächlich zusammengeführt werden soll.)
set(state => ({ /* ... */ }), false, { type: 'INCREASE_WIDGETS' })
Es gibt Argumente dafür, dass die Erhöhungsaktion nicht zwei Ereignisse auslösen sollte, sondern das Ganze eigentlich ein Ereignis sein sollte, das von einem Reduzierer behandelt wird.
increase: () => {
set((state) => ({
widgets: state.widgets + 1,
gizmos: state.gizmos + 3,
}))
}
Ob eine Aktion mehrere Ereignisse auslösen soll oder ob eine Aktion immer einem Ereignis entspricht, ist eine philosophische Frage. Im Allgemeinen sollten Aktionen nur dann mehrere Ereignisse auslösen, wenn die Ereignisse nichts miteinander zu tun haben. In diesem Fall verwenden wir einen vollständig erfundenen Zustand, der nichts mit der Realität zu tun hat, also spielt es keine Rolle, aber in der realen Welt stellen wir fest, dass eine Aktion ein Ereignis erzeugt, als gutes Muster gilt.
Dies wird durch den React-Kontext, in dem wir Aktionen aufrufen und Ereignisse auslösen, noch verstärkt. Die Komponente, in der die Aktion aufgerufen wird, hat ihren eigenen Status, der uns bei der Entscheidung helfen würde, ob ein Ereignis ausgelöst werden soll. Beispielsweise könnte die Komponente Informationen über die Gültigkeit eines Formulars enthalten, und wir sollten nur dann ein Ereignis auslösen, wenn das Formular gültig ist. Es ist oft unnötig, alle Informationen über den Zustand der Komponente in eine Aktion zu übergeben und dann die Aktion entscheiden zu lassen, ob das Ereignis ausgelöst werden soll. Die Komponente entscheiden zu lassen, ob die Aktion überhaupt aufgerufen werden soll, ist viel ergonomischer.
Wenn ein Reducer nur ein Ereignis behandelt und davon ausgegangen wird, dass eine Aktion genau ein Ereignis auslöst, können wir die gesamte ursprüngliche Architektur geschickt abstrahieren. Wir können die Geschäftslogik unserer Zustandsübergänge in Reduktoren schreiben und sie dann mit einem einfachen Mapper in Aktionen umwandeln. Das ist wirklich nett, weil wir uns unsere Zustandsverwaltungslogik völlig getrennt von der Benutzeroberfläche vorstellen können und wir sie sehr einfach testen können, da wir es nur mit reinen Funktionen zu tun haben, die außerhalb eines Kontextes existieren.
// reducers.js
export const increase = state => ({
widgets: state.widgets + 1,
gizmos: state.gizmos + 3,
})
// store.js
import * as reducers from './reducers'
export const useStore = create(set => ({
widgets: 0,
gizmos: 0,
...convertReducersToActions(set, reducers)
}))
// component.js
import { useStore } from './store'
const Component = () => {
const increase = useStore(s => s.increase)
return (
<button onClick={increase}>Increase</button>
)
}
Hier können wir sehen, dass die Logik in den Reduzierern vollständig von der Benutzeroberfläche isoliert ist. Bei den Reduzierern handelt es sich ebenfalls um konventionelle, reine Funktionen, die direkt in unsere Tests importiert werden können. Sie sind nicht in einen Haken gehüllt oder von äußeren Einflüssen abhängig einstellen
Funktion. Wir müssen keine komplizierten Dependency-Injections oder Mocking durchführen, um sie zu testen. Sie können einfach in eine Testdatei importiert und überprüft werden, ob sie wie erwartet funktionieren. Wir können auch zusammengesetzte UI-Muster testen, die aus mehreren Ereignissen bestehen, indem wir die Reduzierer nacheinander auf einen bestimmten Zustand anwenden.
Wenn wir andererseits davon ausgehen, dass die Aktionen immer Ereignisse innerhalb der React-Komponenten auslösen, können wir sie ergonomisch mit anderen Zustandsquellen wie Formular- oder Abrufbibliotheken verbinden. Wir können Aktionen abhängig vom Status der Formularvalidierung von Formik versenden oder den Zustandsstatus mit React Query synchronisieren. Wir delegieren die zustandsabhängige Bedingungslogik an diese anderen Zustandsquellen und behandeln Aktionen als einfache Rückrufe mit Nebeneffekten, die wir ausführen und vergessen können.
Der wichtige Klebstoff zwischen diesen beiden Welten — Reducer als reine, testbare JS-Funktionen und Aktionen als vollständig integrierte React-Callbacks — ist Reduzierungen in Aktionen umwandeln
Nutzfunktion. Es sieht so aus:
export const convertReducersToActions = (set, reducers) => {
const entries = Object.entries(reducers)
const actions = entries.map(([type, fn]) =>
([type, (...args) => set(state => fn(state, ...args), false, { type, ...args })]))
return Object.fromEntries(actions)
}
Im Grunde genommen, angesichts der einstellen
Dieses Tool ist eine Funktion von Zustand und ein Objekt von Reduzierern. Es konvertiert Reduzierstücke, die die Form haben (state,... args) => Zustand
in Aktionsrückrufe mit dem Formular (... args) => nichtig
.
Eine der größten Stärken von Zustand im Vergleich zu Redux ist die Fähigkeit, mehrere unabhängige Geschäfte mit minimalem Aufwand einzurichten. Während Redux ein einzelnes Geschäft mit mehreren Teilen befürwortet, ermöglicht der Ansatz von Zustand eine natürlichere Trennung der Belange.
// userStore.js
const setUser = (state, user) => ({ ...state, user })
const updatePreferences = (state, prefs) => ({
...state,
preferences: {
...state.preferences,
...prefs,
},
})
export const useUserStore = create((set) => ({
user: null,
preferences: {},
...convertReducersToActions(set, { setUser, updatePreferences })
}))
// cartStore.js
export const addItem = (state, item) => ({
items: [...state.items, item]
})
export const removeItem = (state, itemId) => ({
items: state.items.filter(item => item.id !== itemId)
})
export const useCartStore = create((set) => ({
items: [],
...convertReducersToActions(set, { addItem, removeItem })
}))
Diese Trennung bietet mehrere Vorteile. Jedes Geschäft kann unabhängig getestet und gewartet werden, sie können nach Bedarf verzögert geladen werden, verschiedene Teams können verschiedene Filialen besitzen und Leistungsoptimierungen werden detaillierter durchgeführt.
Im Allgemeinen haben wir es für am besten gehalten, State in verschiedene Stores zu unterteilen, wenn garantiert ist, dass sie niemals interagieren müssen. Da Zustand jedoch außerhalb von React verwendet werden kann, ist dies möglich, wenn ein solcher Bedarf besteht. Im Allgemeinen sollte dies jedoch am besten vermieden werden, da dies zu einer sehr engen Kopplung zwischen den Stores führt. Stattdessen können wir individuelle Aktionen von verschiedenen Store verwenden
hakt sich in Komponenten ein und verbindet sie auf Komponentenebene.
JavaScript macht unveränderliche Statusaktualisierungen zwar ausführlich und fehleranfällig, aber wir haben festgestellt, dass funktionale Objektive eine elegante Lösung sind. Anstatt nach Bibliotheken wie Immer zu greifen, verwenden wir eine kleine Sammlung von Objektiv-Utilities, die das Arbeiten mit Nested State ergonomisch, skalierbar und zukunftssicher machen. Wenn Objektive für Sie neu sind, schauen Sie sich unsere an Webinar über funktionale Objektive in JavaScript.
Zusätzlich zu Objektiven können wir Funktionskompositionen verwenden (z. B. die von Lodash) fließen
Funktion), mit der wir einzelne reine Reduktoren zu Updates zusammensetzen können.
import { flow } from 'lodash'
import { lensForProp, over } from './lens'
const widgetsL = lensForProp('widgets')
const gizmosL = lensForProp('gizmos')
const add = a => b => a + b
const increase = state => flow([
state => over(widgetsL, state, add(1)),
state => over(gizmosL, state, add(3)),
])(state)
Wir können auch Objektive für den tiefen Objektzugang zusammenstellen:
import { lensForProp, set, compose } from './lens'
const userPreferencesL = compose(
lensForProp('user'),
lensForProp('preferences'),
lensForProp('theme'),
)
const updateTheme = (state, theme) => set(userPreferencesL, state, theme)
Objektive sind eine Abstraktion gegenüber dem Zugriff auf unveränderliche Objekte und bieten daher mehrere Vorteile gegenüber manuellen Zustandsupdates oder Unveränderbarkeitsbibliotheken. Zusammensetzbare Operationen für komplexe Zustandspfade machen es einfacher, die Logik des Zugriffs auf ein verschachteltes Feld von der Logik der Aktualisierung zu trennen. Objektive können über verschiedene Reduzierstufen hinweg wiederverwendet werden. Wenn sich ein Feldname ändert, muss nur ein einziges Objektiv aktualisiert werden, anstatt auf die Logik im gesamten Code zuzugreifen. Wir können auch komplexere Datenzugriffsmuster mithilfe benutzerdefinierter Objektive definieren und diese wiederverwenden. Schließlich sind Objektive einfach und doch leistungsstark, es gibt keine Laufzeitabhängigkeiten und die gesamte Implementierung besteht aus etwa 15 Codezeilen.
Die Muster, die wir untersucht haben, haben sich in der Produktion in mehreren Anwendungen bewährt. Indem wir die Flexibilität von Zustand genutzt und gleichzeitig klare architektonische Grenzen eingehalten haben, waren wir in der Lage, wartbare, skalierbare Zustandsverwaltungslösungen zu entwickeln, mit denen unsere Teams gerne arbeiten.
Die Kombination aus reinen Reducern, automatischer Aktionserstellung und granularen Speichern hat uns das Beste aus beiden Welten beschert: die Einfachheit und Ergonomie des modernen React-State-Managements mit der Vorhersagbarkeit und Testbarkeit traditionellerer Ansätze.
Denken Sie daran, dass es sich bei diesen Mustern eher um Richtlinien als um strenge Regeln handelt. Passen Sie sie an Ihre spezifischen Bedürfnisse an und scheuen Sie sich nicht, dort abzuweichen, wo es für Ihre Anwendung sinnvoll ist.
Our promise
Every year, Brainhub helps 750,000+ founders, leaders and software engineers make smart tech decisions. We earn that trust by openly sharing our insights based on practical software engineering experience.
Authors
Read next
Popular this month