Einfacheres Redux mit dem Redux Toolkit

Keine Kommentare

Moderne Webanwendungen haben in der Regel nicht nur eine Oberfläche, sondern besitzen auch einen Zustand, der im System abgebildet und gehalten werden muss. Eine der beliebtesten Bibliotheken hierfür ist Redux. Mithilfe von Redux wird ein globaler Store angelegt, der den Zustand der Anwendung hält und zugreifbar macht. Redux bietet erst einmal eine gute Grundlage. Es liefert jedoch für sich alleine noch nicht genug Funktionen für alle Anwendungsfälle. Eine Möglichkeit, das Arbeiten mit Redux zu vereinfachen, ist das Redux Toolkit. Dieses liefert einige nützliche Bibliotheken und hat Komfortfunktionen, um die Syntax von Redux an einigen Stellen etwas verständlicher zu machen. Außerdem wird der Code etwas kompakter, da für vieles Standardwerte verwendet werden.

Das Redux-Toolkit ist hierbei flexibel und lässt sich auch ohne Probleme mit normalen Redux kombinieren. Es können die Funktionen benutzt werden, die im aktuellen Fall Sinn machen und für den Rest der Anwendung wird auf die Standard-Methoden von Redux zurückgegriffen.

Konfiguration einfach gemacht

Um Redux zu konfigurieren wird die createStore-Methode verwendet. Diese Funktion erwartet als Argumente eine Reducerfunktion, einen optionalen initialen State und eine ebenfalls optionale Menge von Enhancer-Funktionen. Beim Aufbau des Stores werden oft verschiedene Enhancer benötigt, um Redux zusätzliche Funktionen hinzuzufügen. Außerdem gibt es oft den Fall, dass man verschiedene Enhancer braucht, wenn man in der Entwicklung oder der Produktivumgebung ist.

Um die Konfiguration zu vereinfachen bietet das Redux Toolkit eine configureStore-Methode, welche sinnvolle Standardwerte, wie z.B. Standard-Enhancer, beim Anlegen des Stores liefert. Die Funktion erwartet ein Konfigurationsobjekt, mit dem versucht wird, die Funktion der createStore-Methode sprechender abzubilden.

Im Konfigurationsobjekt werden verschiedene Eigenschaften des Stores definiert. Folgende Parameter können benutzt werden:

  1. reducer: Es wird ein Objekt übergeben, das aus verschiedenen Keys mit Reducer-Funktionen als Values besteht. Aus diesen einzelnen Reducerfunktionen wird anschließend automatisch der Root-Reducer gebildet. Intern nutzt das Redux Toolkit hierzu die combineReducers-Funktion.
  2. middleware: Als middleware kann eine Menge an Middleware-Funktionen angegeben werden, die den Store um zusätzliche Funktionalität erweitert. Wenn keine angegeben wird, wird ein Satz an Standard-Middlewares verwendet. Wenn hier keine Middleware angegeben wird, wird die getDefaultMiddleware-Funktion benutzt, um einige Standard-Middlewares zu laden. Letztere sind thunk, immutableStateInvariant und serializableStateInvariant, wobei die letzten beiden nur im Development-Modus geladen werden.
  3. devTools: Wenn ein Boolean übergeben wird, bestimmt dieser, ob die ReduxDevTools automatisch konfiguriert werden sollen oder nicht. Falls man ein Objekt übergibt, wird dieses an die composeWithDevTools-Funktion weitergeleitet. Wenn kein Parameter angegeben wird, ist true der Standardwert.
  4. preloadedState: Ein optionaler initialer State, welcher dem Store übergeben wird.
  5. enhancers: Eine optionale Liste von Enhancern. Diese werden intern an die createStore-Methode übergeben. Die applyMiddleware und composeWithDevTools Enhancer sollten hierbei nicht übergeben werden, da diese schon von der configureStore-Methode verwaltet werden.

Ein Beispiel für das Konfigurieren einen Redux-Stores mit verschiedenen Reducern, Middlewares, Unterstützung für die Devtools, einem initial State und einem Enhancer sieht wie folgt aus.

const reducer = {
  todos: todosReducer,
  visibility: visibilityReducer
}

const middleware = [...getDefaultMiddleware(), logger]

const preloadedState = {
  todos: [
    {
      text: 'Eat food',
      completed: true
    }
  ],
  visibilityFilter: 'SHOW_COMPLETED'
}

const store = configureStore({
  reducer,
  middleware,
  devTools: process.env.NODE_ENV !== 'production',
  preloadedState,
  enhancers: [reduxBatch]
})

Mutative Reducers

Die zweite Funktion, die das Redux Toolkit bietet, ist createReducer, welche dabei hilft, simplere Reducer zu schreiben. Der erste Vorteil dieser Methode ist, dass kein Switch-Statement mehr benötigt wird. Der Ansatz mit Switch-Statements funktioniert ansich gut, es entsteht jedoch ein gewisser Boilerplate und man kann leicht Fehler machen, wie z.B. das Vergessen des Default-Cases oder das Setzen des initialen States. Die createReducer-Funktion erwartet zwei Parameter. Einen initialen State und ein Objekt, bei dem die Keys die Action-Types sind, welchen als Value die Reducer-Funktion zugeordnet wird. Hier einmal der Vergleich von einem normalen Redux-Reducer zur createReducer-Funktion:

function counterReducer(state = 0, action) {
  switch (action.type) {
    case 'increment':
      return state + action.payload
    case 'decrement':
      return state - action.payload
    default:
      return state
  }
}

const counterReducer = createReducer(0, {
  increment: (state, action) => state + action.payload,
  decrement: (state, action) => state - action.payload
})

Der zweite Vorteil der createReducer-Funktion ist, dass der State im Reducer nicht mehr als immutable genutzt werden muss. Man kann jetzt im Reducer so arbeiten, als würde man den State direkt ändern. Aus den Änderungen, die am State vorgenommen werden, wird vom Redux Toolkit der neue State generiert. Hierzu wird die Bibliothek immer (https://github.com/immerjs/immer) genutzt. Der Reducer erhält einen Proxy-State, der alle Änderungen in equivalente Copy-Operationen übersetzt. Die Reducer, die mit immer geschrieben sind, sind um den Faktor 2 bis 3 langsamer als ein normaler Redux-Reducer. Hier einmal beispielhaft eine Reducer-Funktion, welche den State als immutable nutzt und eine, welche direkt Änderungen am State vornimmt.

case: {
  const newFilter = state.currentFilter.includes(filterParameter)
    ? state.currentFilter.filter(filter => filter !== filterParameter)
    : [...state.currentFilter, filterParameter];
  return {
    ...state,
    currentFilter: newFilter,
  };
}

[UPDATE_FILTER]: (state, action) => {
  if(state.currentFilter.includes(filterParameter)) {
    state.currentFilter = state.currentFilter.filter(filter => filter !== filterParameter)
  } else {
    state.currentFilter.add(filterParameter)
  }
}

Der Vorteil davon, dass die Änderungen direkt auf dem Element gemacht werden können, ist, dass der Code leichter verständlich wird. Die Änderung und die Regeln, wie es zu der Änderung kommt, stehen im Vordergrund und das Erstellen des neuen States muss nicht beachtet werden.

Der Nachteil hierbei ist, dass man grundlegende Regeln von Redux umgeht und es zu Fehlern kommen kann, falls man aus Versehen solche Änderungen in einem normalen Reducer macht. Außerdem ist die Performance minimal schlechter, was jedoch in den meisten Fällen nicht relevant sein sollte.

Actions mit weniger Boilerplate

Um eine Action in Redux zu erstellen, wird in der Regel eine Konstante für den Type und ein ActionCreator, der diese Konstante verwendet, benötigt. Dies führt zu zusätzlichem Boilerplate-Code. Die createAction-Methode des Redux Toolkits verbindet diese beiden Schritte zu einem. Hier einmal das Beispiel eines normalen Action-Creators und wie dieser mit dem Redux Toolkit erstellt werden kann.

const INCREMENT = 'counter/increment'
function increment(amount) {
  return {
    type: INCREMENT,
    payload: amount
  }
}
const action = increment(3)
// { type: 'counter/increment', payload: 3 }
const increment = createAction('counter/increment')
action = increment(3)
// returns { type: 'counter/increment', payload: 3 }

Diese Funktion hilft dabei, einen großen Teil von Boilerplate zu entfernen. Der Nachteil, den man hierdurch hat, ist, dass bei normalen Actions klar definiert werden kann, was als Payload erwartet wird. Bei den generierten Actions ist der Payload nicht näher definiert und man muss im Reducer prüfen, was als Payload übergeben werden muss.

Actions automatisch generieren

Falls man noch einen Schritt weiter gehen möchte, können die Actions für einen Store auch automatisch generiert werden. Hierzu bietet das Redux Toolkit die createSlice-Methode. Die Methode erwartet als Parameter einen Slice-Namen, einen initialen State und eine Menge an Reducer-Funktionen.

Die createSlice-Methode liefert dann neben dem Reducer eine Menge generierter ActionCreators, die zu den Keys im Reducer passen. Den generierten Actions kann dann der passende Payload gegeben werden und diese können ohne weiteren Aufwand verwendet werden. Das Generieren stößt jedoch relativ schnell an seine Grenzen. Die Abbildung von asynchronen Actions z.B. mit thunk ist schon nicht mehr möglich. Im folgenden Beispiel ist einmal das Nutzen der Funktionen und der entstehenden Action-Creators zu sehen.

const user = createSlice({
  name: 'user',
  initialState: { name: '', age: 20 },
  reducers: {
    setUserName: (state, action) => {
    state.name = action.payload
    },
    increment: (state, action) => {
    state.age += 1
    }
  },
})

const reducer = combineReducers({
  user: user.reducer
})
const store = createStore(reducer)
store.dispatch(user.actions.increment())
// -> { user: {name : '', age: 21} }
store.dispatch(user.actions.increment())
// -> { user: {name: '', age: 22} }
store.dispatch(user.actions.setUserName('eric'))
// -> { user: { name: 'eric', age: 22} }

Die createSlice-Methode kann den benötigten Boilerplate bei manchen Stores stark senken. Sie ist jedoch nicht besonders flexibel und es muss geprüft werden, ob sie für den konkreten Anwendungsfall geeignet ist.

Selektoren mit Reselect

Eine weitere Bibliothek, die im Redux Toolkit enthalten ist, ist Reselect. Mithilfe von Reselect lassen sich materialisierte Selektoren schreiben, welche den Zugriff auf den Store erleichtern und die Performance verbessern können.

Ein Reselect-Selektor leitet aus dem Zustand des Stores einen neuen Wert ab. Hierdurch wird ein einfacher Zugriff auf abgeleitete Elemente aus dem State ermöglicht. Alle Elemente, die abgeleitet werden können, müssen nicht selbst im State gehalten werden, was dazu führt, dass der Store nicht unnötig groß wird. Die Reselect-Selektoren werden nicht bei jeder Änderung am Store neu berechnet, sondern nur wenn sich ihre Eingabeparameter ändern. Hierdurch kann ein zusätzlicher Performancevorteil erreicht werden.

Die einzelnen Selektoren können auch kombiniert werden, sodass das Ergebnis eines Selektors die Eingabe des nächsten ist. Hier ein Beispiel wie die Selektoren genutzt werden können.

const shopItemsSelector = state => state.shop.items
const taxPercentSelector = state => state.shop.taxPercent

const subtotalSelector = createSelector(
  shopItemsSelector,
  items => items.reduce((acc, item) => acc + item.value, 0)
)

const taxSelector = createSelector(
  subtotalSelector,
  taxPercentSelector,
  (subtotal, taxPercent) => subtotal * (taxPercent / 100)
)

Es gibt als erstes zwei Selektoren, die einfach einzelne Elemente aus dem State zurückgeben. Anschließend kann mit dem subtotalSelector der Gesamtpreis aller Elemente aus den Shopitems abgefragt werden. Der taxSelektor kombiniert nun die bestehenden Selektoren, um die Steuern zu berechnen.

Fazit

Das Redux Toolkit liefert eine Reihe von Funktionen, die das Arbeiten mit Redux vereinfachen können. Es deckt viele Standardfälle automatisch ab, lässt sich aber weiterhin für speziellere Aufgaben konfigurieren. Das Redux Toolkit liefert eine Sammlung von Bibliotheken, welche schon häufig genutzt werden und sich gut zusammen einsetzen lassen. Durch diese Bibliotheken und die neuen Funktionen des Redux Toolkits lässt sich eine Menge an Boilerplate Code vermeiden und die Syntax wird an einigen Stelle klarer und verständlicher.

Die Vorteile, die das Redux Toolkit liefert, haben jedoch auch ihren Preis. Man bekommt eine zusätzliche Abhängigkeit durch das Redux Toolkit und indirekt einige Abhängigkeiten durch die vom Toolkit mitgelieferten Bibliotheken. Außerdem werden dem Entwickler durch die Standards im Redux Toolkit einige Aufgaben abgenommen. Somit ist es möglich, dass Wissenslücken im Umgang mit Redux ohne das Toolkit entstehen. Gerade für neue Entwickler werden grundlegende Regeln in der Arbeit mit Redux abstrahiert, was dazu führen kann, dass das Verständnis für Redux und das Flux-Pattern erschwert wird.

Es lässt sich diskutieren, ob das Redux Toolkit mehr Vor- als Nachteile liefert. Ein Projekt auf das Redux Toolkit umzustellen, ist jedoch problemlos möglich. Die einzelnen Funktionen nutzen intern weiter die normale Redux-Funktionalität, somit können die einzelnen Teile des Stores auch iterativ auf das Toolkit umgestellt werden und es muss nicht in einem großen Umbau passieren. Außerdem muss nicht immer der volle Funktionsumfang des Redux Toolkits genutzt werden, sondern man kann sich auf die Funktionen beschränken, die einen Vorteil in der aktuellen Situation liefern.

Enno Lohmann

Enno ist seit 2018 als Software Engineer bei der codecentric AG tätig. Sein Schwerpunkt liegt auf der Fullstack-Java-Entwicklung mit Spring Boot und modernen Webframeworks. Außerdem interessiert er sich für Cloud- und Containertechnologien.

Über 1.000 Abonnenten sind up to date!

Die neuesten Tipps, Tricks, Tools und Technologien. Jede Woche direkt in deine Inbox.

Kostenfrei anmelden und immer auf dem neuesten Stand bleiben!
(Keine Sorge, du kannst dich jederzeit abmelden.)

* Hiermit willige ich in die Erhebung und Verarbeitung der vorstehenden Daten für das Empfangen des monatlichen Newsletters der codecentric AG per E-Mail ein. Ihre Einwilligung können Sie per E-Mail an datenschutz@codecentric.de, in der Informations-E-Mail selbst per Link oder an die im Impressum genannten Kontaktdaten jederzeit widerrufen. Von der Datenschutzerklärung der codecentric AG habe ich Kenntnis genommen und bestätige dies mit Absendung des Formulars.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.