React.js State Management mit MobX

Keine Kommentare

Wenn man sich den heutigen Stand von React.js anschaut, könnte man denken, dass das State Handling über Hooks wie useContext und useReducer kein Problem mehr darstelle und neben React.js keine Third-Party-Lösungen dafür erforderlich seien. In der Praxis spielen für uns aber auch Aspekte wie Wartbarkeit und Testbarkeit während der Entwicklung eine große Rolle. Daher möchte ich im Folgenden mit MobX anhand einiger Beispiele einen Ansatz vorstellen, der in Kombination mit klassischen React-Klassen, aber gerade auch mit React-Hooks zu einer sauberen Trennung zwischen State Handling und -Rendering führt. Zudem vereinfacht es das Testen von Frontend-Logik, das ohne spezielles Tooling wie z. B. mittels react-testing-library auskommt.

Mein Weg zu MobX

Bei der Entwicklung nicht-trivialer React-Anwendungen hat das State Management für mich immer schon einen hohen Stellenwert gehabt. Nach einem ersten Projekt mit Yahoos Framework Fluxible – einer Implementierung des Flux-Patterns – habe ich auch einige Services unter Verwendung von Redux entwickelt. Vielleicht liegt es an meinem Java-Background, dass ich mit den Umsetzungen nicht gänzlich zufrieden war.

Fluxible kam insbesondere meiner Neigung zu objektorientiertem Denken und DDD entgegen. In den Fluxible Stores konnte ich unabhängig von der Darstellung sowohl den State als auch die Logik zu dessen Manipulation kapseln und sehr einfach über Unit-Tests testen. Allerdings machte der Zwang zur Verwendung von Payload-Objekten den Code nicht unbedingt lesbarer. Außerdem war es Methoden in den Stores nicht erlaubt, asynchrone Operationen auszuführen. Für diese wiederum wurde das Konzept der Actions und ein Dispatch-Mechanismus zur Verfügung gestellt. Zudem gab es wenig Unterstützung zur Optimierung des Renderings, was insbesondere auf Seiten mit vielen React-Komponenten zu Problemen führte.

Bei Redux sah das schon ganz anders aus – React-Redux war in der Lage, feingranular über die Notwendigkeit des Renderings zu entscheiden. Allerdings mussten wir hierfür die gewohnten Implementierungs-Pfade verlassen und statt Objektorientierung und Datenlokalität wurde zu eher strukturierter Programmierung gewechselt und in Thunk-Actions und Reducer-Logik gedacht. Die Gebote der Immutability und Normalisierung sorgten insbesondere für längeren (und damit weniger lesbaren) Code. Hinzu kam ein Wechsel von ES6 auf TypeScript, der uns neben vielen Vorteilen insbesondere im Umfeld von Redux zusätzlichen Typing-Boilerplate-Code bescherte. Insgesamt war der entstandene Code eher technisch getrieben.

Nachdem in einem Talk auf der EnterJS 2017 MobX in einem Nebensatz erwähnt wurde, war ich neugierig, was dieses Framework zu bieten hat und ob es eine ernsthafte Alternative zum damaligen Platzhirsch Redux sein konnte. Ich war mehr als positiv überrascht!

Grundlagen von MobX

MobX liegt ein einfaches wie mächtiges Konzept zugrunde: die konsequente Umsetzung des Observable Patterns im Umgang mit State. Das lässt sich am besten an einem kleinen Beispiel verdeutlichen.

import { observable, autorun } from 'mobx';

class Counter {
  @observable
  private value = 0;

  get text() {
    return `Current counter: ${this.value}`;
  }

  public increase() {
    this.value++;
  }
}

const counter = new Counter();

autorun(() => {
  console.log(counter.text);
});
// console log: "Current Counter: 0"

counter.increase();
// console log: "Current Counter: 1"
counter.increase();
// console log: "Current Counter: 2"

Der State wird hierbei durch die Instanz von Counter repräsentiert. Die Klasse selbst ist nicht weiter spektakulär: eine Instanzvariable, ein getter, eine Methode, die die Änderung des State kapselt.

Die einzige Anpassung für MobX ist der @observable Decorator an der Instanzvariable. Dieser führt dazu, dass die Instanzvariable durch Getter/Setter ersetzt wird. Jeder lesende und schreibende Aufruf kann jetzt von MobX überwacht werden. Wer aktuell noch keine Decorators einsetzen will: Deren Verwendung ist generell optional.

Ein einfacher Weg zur Registrierung eines Observers ist die Verwendung der Methode mobx.autorun(). Der Methode wird eine Callback-Funktion übergeben, die zunächst synchron ausgeführt wird. Während der Ausführung werden alle direkten und indirekten Zugriffe auf Observables erkannt, in diesem Fall der indirekte Zugriff über Counter.text() auf dessen value-Property.

Ändert sich zukünftig der Wert eines dieser Observables, wird die Callback-Funktion von MobX automatisch erneut aufgerufen. Diese geschieht im Beispiel durch den Aufruf der increase()-Methode.

Observables

Observables lassen sich entweder in ES6-Klassen wie zuvor beschrieben über den @observable Decorator erstellen oder durch Übergabe eines einfachen POJOs:

import { observable } from 'mobx'

const objectWithObservableProperties = observable({
  value: 0
})

Auch komplexere Objekte lassen sich observieren, dazu werden z. B. Arrays und ES6 Maps deren Observable-Entsprechungen konvertiert:

import { observable, autorun } from 'mobx';

const list = observable([0, 1, 2])

autorun(() => {
  console.log(list[1])
})
// console log: "1"

list[2] = 5
// console log: "1"

list[1] = 4
// console log: "4"

list.splice(1, 1)
// console log: "5"

list.replace([10, 11])
// console log: "11"
// list = [10, 11] wird hingegen nicht triggern

Im letzten Beispiel verwenden wir die replace() Methode des Observable Arrays. Die Ersetzung der Referenz auf das Array wird von MobX in diesem Fall nicht als Änderung erkannt. Aus dem gleichen Grund gibt es auch noch die clear()-Methode. Es empfiehlt sich daher, die Variablen direkt mit const bzw. in TypeScript als readonly zu deklarieren.

Actions

Erfordert eine Änderung des Observable State mehrere Operationen, möchte man üblicherweise nicht, dass Seiteneffekte nach jedem Einzelzugriff erfolgen. Um das zu verhindern, kann man mehrere Operationen in eine Action kapseln.

Im Beispiel des Counters oben geht das z. B. so:

import { runInAction } from 'mobx';

// counter === 1
runInAction(() => {
  counter.increase();
  counter.increase();
})
// console log: "Current Counter: 3"

Eleganter lässt sich das als Methode innerhalb der Klasse Counter abbilden:

import { action } from 'mobx';

class Counter {
  // ...

  @action
  public countTwice() {
    this.increase();
    this.increase();
  }
}

Da es schnell passieren kann, dass man den action-Wrapper vergisst, lässt sich dessen Verwendung zur Laufzeit erzwingen. Über die empfohlene Einstellung mobx.configure({ enforceActions: 'observed' }) erhält man dann eine Fehlermeldung, wenn man Werte von Observables, die bereits an einen Observer gebunden sind, ohne Action-Wrapping ändert. Dies hat den Vorteil, dass zum Initialisierungszeitpunkt noch keine Action benötigt wird.

Bei asynchronen Operationen ist zu beachten, dass nur synchrone Änderungen Teil der Action sind. Asynchrone Änderungen benötigen daher eine eigene Action-„Klammer“. Es gibt dazu je nach Anwendungsfall verschiedene Ansätze. Eine einfache Methode ist es, den jeweils synchronen Teil in eine eigene @action Funktion auszulagern:

import { observable, action } from 'mobx';
import request from 'superagent'

class PersonalDataFormStore {
    @observable
    public fullName = ''

    @observable
    public email = ''

    @action
    public clearForm() {
        this.fullName = ''
        this.email = ''
    }

    public async submit() {
        await request.post('...')
        this.clearForm()
    }
}

Computed Properties

Gerade beim Rendering von Web-Content benötigt man oft nicht nur die Originaldaten, sondern auch abgeleitete Informationen, z. B. im letzten Beispiel, ob das Formular valide ist. Dies lässt sich sehr elegant über Computed Properties abbilden:

import { observable, computed, autorun } from 'mobx';

class PersonalDataFormStore {
    @observable
    public fullName = ''

    @observable
    public email = ''

    @computed
    get valid() {
        return this.fullName.length > 0 && this.email.length > 0
    }
}

const store = new PersonalDataFormStore()

autorun(() => {
    console.log(`Valid: ${store.valid}`)
})
// console log: "Valid: false"

store.fullName = 'Carsten Rohrbach'
store.email = 'noreply@codecentric.de'
// console log: "Valid: true"

Abgeleiteten State so über Properties abzubilden hat den Vorteil, dass die darauf zugreifenden React-Komponenten so simpel wie möglich sind und der Wert von valid mit Unit Tests sehr einfach geprüft werden kann.

Die @computed Annotation bewirkt, dass valid einerseits Observer von fullName und email wird, selbst aber auch als Property observable wird. Wie im Beispiel zu sehen, führt dann nicht mehr jede Änderung an fullName oder email zu einer Auslösung von Seiteneffekten, sondern nur Änderungen am Ergebnis von valid selbst.

MobX in Kombination mit React

Zur Zeit existieren zwei Module, die die Funktionalität von MobX mit React nutzbar machen. mobx-react-lite ist eine schlanke Lösung für funktionale React-Komponenten und Hooks. Mit mobx-react bekommt man dagegen das Komplettpaket, das seit Version 6 ebenfalls ‚mobx-react-lite‘ integriert, aber auch die klassischen React-Klassen unterstützt und bei einer evtl. bevorstehenden Migration zu Hooks die richtige Wahl ist. Ich werde im Folgenden in meinen Beispielen der Lesbarkeit halber den React-Hook Style verwenden.

Mit dem zuletzt vorgestellten PersonalDataFormStore lässt sich nach Installation folgende React-Komponente erstellen:

import * as React from 'react'
import { useContext } from 'react'
import { PersonalDataFormStore } from './PersonalDataFormStore'
import { observer } from 'mobx-react-lite'

const personalDataFormContext = React.createContext(new PersonalDataFormStore())

export const PersonalDataForm = observer(() => {
    const store = useContext(personalDataFormContext)
    const { fullName, email, valid } = store

    return (
        <div>
            <div>
                <label htmlFor='fullName'>Full name:</label>
                <input id='fullName' type='text' value={fullName} onChange={event => (store.fullName = event.target.value)} />
            </div>
            <div>
                <label htmlFor='email'>E-mail:</label>
                <input id='email' type='text' value={email} onChange={event => (store.email = event.target.value)} />
            </div>
            <div>
                <input type='submit' disabled={!valid} onClick={() => store.submit()} />
            </div>
        </div>
    )
})

Die Komponente stellt ein Formular mit zwei Feldern dar, sowie einen Submit-Button, der aber erst aktiv ist, wenn alle Felder valide sind.

Der State dazu liegt in einer Instanz von PersonalDataFormStore. Damit die Komponente darauf zugreifen kann, nutzt sie den React-Hook useContext.

Außerdem muss die Komponente sich beim ersten Rendering als Observer registrieren. Dazu reicht es, die Komponente in die Funktion observer() zu wrappen. Wird das vergessen, reagiert die Komponente nicht auf Änderungen, z. B. bei Änderungen der Input-Feld-values, und die Eingabefelder lassen sich nicht benutzen.

Ein wenig Refactoring

Das Beispiel würde man natürlich in der Praxis anders implementieren. Jedes Eingabefeld benötigt üblicherweise neben dem Feldwert Informationen zu Validität und Fehlermeldungen. In diesem Fall würde man den Store eher wie hier als Komposition von Feld-Instanzen abbilden:

import { action, computed, observable } from 'mobx'

type Validator = (value: string) => string | undefined

export class FormField {
    @observable
    public value: string = ''

    @computed
    get valid(): boolean {
        return !this.validator || !this.validator(this.value)
    }

    @computed
    get errorMessage(): string | undefined {
        return this.validator ? this.validator(this.value) : undefined
    }

    constructor(public name: string,
                public label: string,
                private validator?: Validator) {}

    @action
    public clear() {
        this.value = ''
    }

    @action
    public updateValue(newValue: string) {
        this.value = newValue
    }
}
import { action, computed, observable } from 'mobx'
import request from 'superagent'
import { validEmail, maxLength } from './Validators'
import { FormField } from './FormField'

export class PersonalDataFormStore {
    public fullName = new FormField('fullName', 'Full name', maxLength(6))
    public email = new FormField('email', 'E-Mail', validEmail())

    @computed
    get valid() {
        return this.fullName.valid && this.email.valid
    }

    @action
    public clearForm() {
        this.fullName.clear()
        this.email.clear()
    }

    public async submit() {
        await request.post('...')
        this.clearForm()
    }
}

Wie man sieht, muss der State nicht innerhalb einer einzigen Instanz liegen. Tatsächlich spielt es für MobX gar keine Rolle, auf welche Klassen und Instanzen der State verteilt ist. Wichtig ist nur, dass beim Rendering direkt oder indirekt auf Observable Properties zugegriffen wird.

Auf React-Komponenten-Ebene gibt es ebenfalls noch Bedarf zur Optimierung:

import * as React from 'react'
import { observer } from 'mobx-react-lite'
import { FormField } from './PersonalDataFormStore'
import { useStores } from './stores'

export const PersonalDataForm = () => {
    const { personalData } = useStores()

    const InputField = observer(({ field }: { field: FormField }) => (
        <div>
            <label htmlFor={name}>{field.label}</label>
            <input id={name} type='text' value={field.value} onChange={event => field.updateValue(event.target.value)} />
        </div>
    ))

    const SubmitButton = observer(() => (
        <div>
            <input type='submit' disabled={!personalData.valid} onClick={() => personalData.submit()} />
        </div>
    ))

    return (
        <div>
            <InputField field={personalData.fullName} />
            <InputField field={personalData.email} />
            <SubmitButton />
        </div>
    )
}

Da die Eingabefelder jetzt in einer eigenen Funktion abgebildet sind, ist es nun umso einfacher, eine eigene funktionale Komponente für ein Einzelfeld zu erstellen. Jedes Einzelfeld wird dann separat als observer() gewrapped. Es ist Best Practice bei MobX, Observer möglichst feingranular zu schachteln. Dies hat den Vorteil, dass sich Änderungen an einzelnen Observable Properties nur auf die Teile des View-Renderings auswirken, die unmittelbar betroffen sind. Im obigen Beispiel würde beim Tippen ins fullName Feld nicht die komplette <PersonalDataForm/>-Komponente neu evaluiert, sondern nur das entsprechende <InputField/>.

Unit Testing

Mit der vorgenommenen Aufteilung kann das Verhalten der Anwendung sehr einfach anhand der State-Klassen getestet werden. Im folgenden Beispiel verwende ich dazu Mocha und Chai:

import { expect } from 'chai'
import { maxLength, required } from './Validators'
import { FormField } from './FormField'

describe('FormField', () => {
    it('should provide an initial empty value', () => {
        expect(new FormField('myField', 'My field').value).to.equal('')
    })

    it('should be valid when no validator is configured', () => {
        expect(new FormField('myField', 'My field').valid).to.be.true
    })

    it('should be valid if the validator succeeds', () => {
        expect(new FormField('myField', 'My field', maxLength(5)).valid).to.be.true
    })

    it('should be valid if the validator succeeds', () => {
        expect(new FormField('myField', 'My field', maxLength(5)).valid).to.be.true
    })

    it('should be invalid if the validator fails', () => {
        expect(new FormField('myField', 'My field', required()).valid).to.be.false
    })

    it('should have an error message if the validator fails', () => {
        expect(new FormField('myField', 'My field', required()).errorMessage).to.equal('required')
    })

    it('should allow updating the field value', () => {
        const field = new FormField('myField', 'My field')
        field.updateValue('123')
        expect(field.value).to.equal('123')
    })
})

Wie im Beispiel zu sehen ist, enthalten weder die State-Klassen noch die Tests Referenzen auf React. Es ist kein Tooling wie Enzyme und so auch keinerlei entsprechender Boilerplate-Code erforderlich.

Fazit

Die Verwendung von MobX bei der Abbildung von clientseitigem State ermöglicht eine klare Trennung des Anwendungsverhaltens gegenüber der Rendering-„Logik“ und vereinfacht das Schreiben von Unit-Tests erheblich. Somit lassen sich auch komplexere Szenarien mit gut zu wartendem Code abbilden.

Ich hoffe, diese kleine Einführung hat euch auf den Geschmack gebracht. Natürlich kann ich hier nicht den ganzen Funktionsumfang abdecken und es gibt in der Praxis den einen oder anderen Fallstrick. Hierzu möchte ich auf die ausführliche Dokumentation auf der MobX Homepage und dem mobx-react Tutorial verweisen.

Natürlich muss man im eigenen Projekt das Rad nicht neu erfinden und sein eigenes Form-Handling „from scratch“ implementieren. Wir haben im aktuellen Projekt unter Anderem das Node-Modul mobx-binder als Open-Source zur freien Verwendung freigegeben.

Weitere Infos:

Carsten Rohrbach

Als langjähriges Mitglied der codecentric unterstützt Carsten Rohrbach in zahlreichen Kundenprojekten umfassend bei allen technischen Aspekten der Softwareentwicklung, sei es Architektur und Design, DevOps oder Agile Praktiken. Aktuell beschäftigt er sich verstärkt mit Oberflächentechnologien im Java und Javascript-Umfeld. Er ist einer der Autoren des Buchs „Vaadin – der Kompakte Einstieg für Java-Entwickler“.

Ü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.