Christian Kruse: React-SPA: best practice, um mit Aktionen nach einer Action umzugehen?

Moin zusammen,

ich schreibe gerade an einer SPA in React. Dort habe ich Aktionen, die weitere Aktionen nach sich ziehen können. Zum Beispiel: der User erstellt ein Objekt. Nach der erfolgreichen Erstellung möchte ich ihn vom Formular weiterleiten an z.B. die neu erzeugte Ressource. Zur Zeit verwende ich dazu den Promise-Mechanismus über redux-thunk:

function createFoo(foo) {
  return dispatch => {
    return axios.post("/foos", { foo })
      .then(
        result => dispatch(createFooSuccess(result)),
        error => dispatch(createFooFailure(error))
      );
  }
}

// …

class FooForm extends React.Component {
  onSave = () => {
    this.props.createFoo(this.state.values).then(() => {
      this.props.history.push("/foos");
      this.props.addSuccessFlash("Foo wurde erfolgreich gespeichert");
    });
  }

  // …
}

Diese Verfahrensweise funktioniert eigentlich ganz gut, sie hält die Actions klein und sorgt so dafür, dass ich sie an anderen Stellen wiederverwenden kann.

Leider gibt es aber auch das Problem, dass ich Fehler, die von der API kommen, nicht mitbekomme. Das durch Redux-Thunk zurück gegebene Promise läuft nie in den Catch-Fall, es löst nur immer die then-Aktion aus.

Was ist denn hier der Best-Practice-Weg, um damit umzugehen?

Ich könnte natürlich auch meine Redirections und dergleichen in die Actions verlagern und damit die Business-Logik komplett raushalten aus meinen Components… leider fehlt mir da noch etwas die Erfahrung um sagen zu können, was denn der Weg mit den wenigsten Nachteilen ist, um mit diesem Problem umzugehen.

LG,
CK

P.S. wie „man“ das in Perl macht interessiert mich nicht.

  1. Tach!

    Leider gibt es aber auch das Problem, dass ich Fehler, die von der API kommen, nicht mitbekomme. Das durch Redux-Thunk zurück gegebene Promise läuft nie in den Catch-Fall, es löst nur immer die then-Aktion aus.

    Ich verwende nur das was in Angular drin ist, also RxJS. Und vielleicht weißt du bereits das, was ich an allgemeinen Dingen sagen kann. In Angular werden neben Netzwerkfehlern auch die Status 4xx und 5xx zu Fehlern umgeschrieben und landen im catch. Das ist wohl aber nicht überall üblich, beziehungsweise einige vertreten die Ansicht, dass es kein HTTP-Fehler ist, wenn die Anwendung nicht kann. Fehler haben dann als 200 zurückzugehen und müssen auf Anwendungsebene behandelt werden, durch Unterscheidung, ob ein Error-Objekt oder ein Datenobjekt in der Antwort steckt.

    Eine weitere Erkenntnis, die ich die Tage erst machen musste: RxJS arbeitet ja mit Observables, quasi eine Weiterentwicklung der Promises. Events werden sozusagen als Array betrachtet, oder zumindest etwas, über das man iterieren und auf das man array-like Operatoren (map, filter, etc.) anwenden kann. Das betrifft auch Einmal-Events wie eine Response auf einen HTTP-Request. Als Verwender erstellt man eine Subscribtion und von der wird im Falle eines neuen Ereignisses der next-Handler oder der error-Handler aufgerufen. Nun ist es aber so, dass ein Fehler ein Observable kaputtmacht. Wenn die Subscriber also einen error-Callback-Aufruf bekommen, melden sie sich vom Observable ab. Neue Subscriber erkennen den Fehlerzustand und melden sich gleich gar nicht erst an. Wenn man also abwechselnd gute und schlechte Nachrichten senden möchte, ohne dass der Stream kaputtgeht, ist man auch hier gezwungen, Fehler eine Ebene weiter oben anzusiedeln. Die beiden Vorschläge waren, alles über next abzuwickeln und mit Fallunterscheidung die guten von den Fehler-Objekten zu unterscheiden, oder zwei Streams zu nehmen, über den einen kommen die Gut-Meldungen, über den anderen die Fehlermeldungen. Sieht beides nicht toll aus. next- und error-Handler wäre einfacher zu implementieren und zu verstehen gewesen. Mit anderen Worten: Das Behandeln(müssen) von Fehlern in der Anwendungsschicht ist durchaus gewollt.

    dedlfix.

    1. Hallo dedlfix,

      Ich verwende nur das was in Angular drin ist, also RxJS. Und vielleicht weißt du bereits das, was ich an allgemeinen Dingen sagen kann. In Angular werden neben Netzwerkfehlern auch die Status 4xx und 5xx zu Fehlern umgeschrieben und landen im catch. Das ist wohl aber nicht überall üblich, beziehungsweise einige vertreten die Ansicht, dass es kein HTTP-Fehler ist, wenn die Anwendung nicht kann. Fehler haben dann als 200 zurückzugehen und müssen auf Anwendungsebene behandelt werden, durch Unterscheidung, ob ein Error-Objekt oder ein Datenobjekt in der Antwort steckt.

      Hm. Das heisst, du leitest Fehler an die Components weiter? In meinem Beispiel würde also createFooFailure() die Fehlermeldungen in den Store packen und die Komponente würde darauf reagieren?

      Wie gehst du dann mit dem Erfolgsfall um? Ich bekomme das Response-Objekt ja nie zu sehen in der Komponente, das Redux-Thunk-Promise gibt mir das nicht weiter. Wobei… jetzt hast du mich unsicher gemacht, ich überprüfe das nochmal 😉

      Ich könnte natürlich auch statt mit dem Redux-Thunk-Promise in der Komponente zu arbeiten mit den Store-Properties arbeiten und z.B. ein Flag success setzen. Wenn das wahr ist, wird die Weiterleitung ausgelöst.

      Je mehr ich darüber nachdenke, desto mehr glaube ich, dass es sinnvoller ist, die Business-Logik in die Actions auszulagern… grübel

      LG,
      CK

      1. Tach!

        Hm. Das heisst, du leitest Fehler an die Components weiter? In meinem Beispiel würde also createFooFailure() die Fehlermeldungen in den Store packen und die Komponente würde darauf reagieren?

        Kommt darauf an. Wenn die Komponente wissen muss, dass es einen Fehler gab, dann muss sie irgendwie informiert werden. Ansonsten muss der Service, der die schutzige Arbeit erledigt, den Fehler abfangen und geeignete Maßnahmen ergreifen. Wenn die Komponenten aufgrund des Fehlers beispielsweise lediglich keine Daten anzeigen kann, dann bekommt sie halt ein null oder leeres Objekt über den next-Handler reingereicht. Zumindest wenn es sich um einen Event-Stream handelt. Bei Einmal-Dingen ist es aber auch egal, ob der Stream danach kauptt ist oder nicht, dann geht's auch über den Error-Callback.

        Wie gehst du dann mit dem Erfolgsfall um? Ich bekomme das Response-Objekt ja nie zu sehen in der Komponente, das Redux-Thunk-Promise gibt mir das nicht weiter. Wobei… jetzt hast du mich unsicher gemacht, ich überprüfe das nochmal 😉

        Vielleicht bin ich dir da keine große Hilfe weil ich deinen Fall so nicht habe oder vielleicht auch nicht ganz verstehe. Bei mir ist es meist so, dass ich einen HTTP-Request abschicke und die Response bekommt direkt der eigentliche Empfänger. Oder aber der Service hängt sich in die Antwort und wendet ein paar Operatoren drauf an, wie Mapping, bevor die Antwort zum Empfänger geht. Früher musste man noch dazwischengehen und das JSON zum Objekt parsen, aber das erledigt mittlerweile Angular (wenn man nicht explizit die nackige Response anfordert).

        Wenn es sich lediglich um ein Daten-Eintragen handelt und für den Anwender danach der Bildschirm gewechselt werden soll (z.B. vom Eingabeformular zur Listendarstellung), dann wird das bei Angular ein Aufruf von navigate() des Routers, und der Empfänger bekommt nichts mehr mit. Im Fehlerfall bleibt aber das Formular stehen und wird um die Meldungstexte ergänzt. Ich gebe aber auch keine optische Gut-Meldung an den Anwender. Erfolgreiches Ausführen ist das was man einfach erwartet. Die Meldung liest man eh nicht mehr, wenn sie ein weiteres Mal auftaucht, oder auch nur irgendein anderer grüner Toast irgendwo zu sehen ist.

        Aber für den Fehlerfall bemühe ich meinen Message-Service, der einen Meldungsdialog anzeigt. Der Service gibt ein Promise/Observable zurück, das erfolgreich ist, wenn der Anwender die Meldung mit OK wegklickt. So kann ich auch erst dann reagieren, wenn der Anwender dazu bereit ist.

        dedlfix.

  2. P.S. wie „man“ das in Perl macht interessiert mich nicht.

    Es ist keine Frage der Programmiersprache. Sondern die Frage inwieweit man einen HTTP Status in seine Anwendungen einbezieht. Also eine Frage der Transparenz des Transportlayers HTTP.

    MfG

    1. Hallo pl,

      Sondern die Frage inwieweit man einen HTTP Status in seine Anwendungen einbezieht. Also eine Frage der Transparenz des Transportlayers HTTP.

      Nein.

      LG,
      CK

  3. Leider gibt es aber auch das Problem, dass ich Fehler, die von der API kommen, nicht mitbekomme. Das durch Redux-Thunk zurück gegebene Promise läuft nie in den Catch-Fall, es löst nur immer die then-Aktion aus.

    Wenn ich mich nicht täusche hat das Problem letztlich mit redux-thunk wenig zu tun. Was du beschreibst ist das Standard-Verhalten von Promises:

    return axios.post("/foos", { foo })
      .then(
        result => dispatch(createFooSuccess(result)),
        error => dispatch(createFooFailure(error))
      );
    

    Du bekommst einen Promise von axios.post() und "catcht" den Fehler durch Angabe einer onRejected-Funktion. Dadurch gilt der Fehler erst einmal als behandelt, als wäre er nicht passiert.

    Die onRejected-Funktion kann einen Alternativwert zurückgeben. Damit wird der neue, von then() zurückgegebene Promise resolved. Wenn dispatch und createFooFailure keine Exceptions auslösen, so wird der neue Promise immer resolved.

    Um das Verhalten zu erklären bietet es sich an, das synchrone Pendant zu Promises zu betrachten: try-catch. Was du machst ist äquivalent zu:

    let result;
    try {
      result = axios.post(...);
      dispatch(createFooSuccess(result));
    } catch (e) {
      dispatch(createFooFailure(error));
    }
    // Weiterer Code, der annimmt, dass result gefüllt ist
    

    Der "weitere Code" wird hier immer ausgeführt, auch wenn der POST fehlgeschlagen ist.

    Wie kann man das verhindern? Indem man den Fehler nach der Behandlung noch einmal mit "throw" auslöst (rethrow).

    let result;
    try {
      result = axios.post(...);
      dispatch(createFooSuccess(result));
    } catch (e) {
      dispatch(createFooFailure(error));
      throw error;
    }
    // Weiterer Code, der jetzt nur ausgeführt wird, wenn der POST erfolgreich war.
    

    Bei Promises ist es ähnlich: Wenn man will, dass der neue Promise rejected wird, muss man im onRejected den "gefangenen" Fehler noch einmal "werfen".

    function createFoo(foo) {
      return dispatch => {
        return axios.post("/foos", { foo })
          .then(
            result => dispatch(createFooSuccess(result)),
            error => {
              dispatch(createFooFailure(error));
              throw error;
            }
          );
      };
    }
    

    Jetzt ist der neue, von then() erzeugte Promise im Erfolgsfalle resolved und im Fehlerfalle rejected.

    Wenn du jetzt in einem anderen Action Creator obigen benutzt:

    dispatch(createFoo(foo)).then(
      () => {
        dispatch(anotherActionCreator());
      }
    );
    

    Dann wird anotherActionCreator nur aufgerufen, wenn createFoo erfolgreich war.

    Dieses "Rethrowing" kann man natürlich kapseln, sodass man es nicht ausdrücklich schreiben muss und nicht vergessen kann.

    Ich könnte natürlich auch meine Redirections und dergleichen in die Actions verlagern und damit die Business-Logik komplett raushalten aus meinen Components… leider fehlt mir da noch etwas die Erfahrung um sagen zu können, was denn der Weg mit den wenigsten Nachteilen ist, um mit diesem Problem umzugehen.

    Das ist der Weg, den ich empfehlen würde. Diese Logik ist m.M.n. in den Actions besser aufgehoben und dort auch einfacher zu testen. Durch redux-thunks Promise-Mechanismus können Async Action Creators einfach aufeinander aufbauen.

    Die Komponente muss dann auch einen High-Level Action Creator als prop bekommen.

    Bernd das Brot

    1. Hallo Bernd,

      danke für deine Antwort.

      Du bekommst einen Promise von axios.post() und "catcht" den Fehler durch Angabe einer onRejected-Funktion. Dadurch gilt der Fehler erst einmal als behandelt, als wäre er nicht passiert.

      Die onRejected-Funktion kann einen Alternativwert zurückgeben. Damit wird der neue, von then() zurückgegebene Promise resolved. Wenn dispatch und createFooFailure keine Exceptions auslösen, so wird der neue Promise immer resolved.

      Ja, das warum habe ich mir schon erklären können. Meine Frage bezog sich darauf, wie ich damit am besten umgehe :-)

      Wie kann man das verhindern? Indem man den Fehler nach der Behandlung noch einmal mit "throw" auslöst (rethrow).

      let result;
      try {
        result = axios.post(...);
        dispatch(createFooSuccess(result));
      } catch (e) {
        dispatch(createFooFailure(error));
        throw error;
      }
      // Weiterer Code, der jetzt nur ausgeführt wird, wenn der POST erfolgreich war.
      

      Bei Promises ist es ähnlich: Wenn man will, dass der neue Promise rejected wird, muss man im onRejected den "gefangenen" Fehler noch einmal "werfen".

      function createFoo(foo) {
        return dispatch => {
          return axios.post("/foos", { foo })
            .then(
              result => dispatch(createFooSuccess(result)),
              error => {
                dispatch(createFooFailure(error));
                throw error;
              }
            );
        };
      }
      

      Jetzt ist der neue, von then() erzeugte Promise im Erfolgsfalle resolved und im Fehlerfalle rejected.

      Wenn du jetzt in einem anderen Action Creator obigen benutzt:

      dispatch(createFoo(foo)).then(
        () => {
          dispatch(anotherActionCreator());
        }
      );
      

      Dann wird anotherActionCreator nur aufgerufen, wenn createFoo erfolgreich war.

      Hm. Das ist eine gute Idee. Darauf bin ich tatsächlich nicht gekommen. Würdest rethrows empfehlen?

      Wie gesagt, ich suche hier ausdrücklich nach den best practices. Mir fehlt bei SPAs im allgemeinen und React im speziellen noch die Erfahrung, um beurteilen zu können, welcher Weg der beste ist. Ich habe zwar gewisse Einblicke und Ideen, aber mangels Team fehlt mir der kritische Blick eines dritten 😉

      Dieses "Rethrowing" kann man natürlich kapseln, sodass man es nicht ausdrücklich schreiben muss und nicht vergessen kann.

      Wie würdest du das implementieren?

      Ich könnte natürlich auch meine Redirections und dergleichen in die Actions verlagern und damit die Business-Logik komplett raushalten aus meinen Components… leider fehlt mir da noch etwas die Erfahrung um sagen zu können, was denn der Weg mit den wenigsten Nachteilen ist, um mit diesem Problem umzugehen.

      Das ist der Weg, den ich empfehlen würde. Diese Logik ist m.M.n. in den Actions besser aufgehoben und dort auch einfacher zu testen. Durch redux-thunks Promise-Mechanismus können Async Action Creators einfach aufeinander aufbauen.

      Ja, je mehr ich darüber nachdenke, desto mehr glaube ich das tatsächlich auch. Wie gehst du denn damit um, dass man ggfls Requests canceln will, etwa weil der User wild herumklickt und damit mehrere Actions auf einmal auslöst? Etwa fetchUsers, fetchPosts, fetchPost und fetchComments, in der Reihenfolge? In dem Fall könnte man fetchUsers und fetchPosts canceln.

      Die Komponente muss dann auch einen High-Level Action Creator als prop bekommen.

      Du meinst, dass ich den Action Creator weiter zerlege in kleinere, die nacheinander dispatcht werden und die Komponente dann nur den „Haupt-Action-Creator“ bekommt? Falls ja: ja, das war mein Gedanke dabei, die Business-Logik in die Actions zu verlegen.

      LG,
      CK

      1. Tach!

        Wie gehst du denn damit um, dass man ggfls Requests canceln will, etwa weil der User wild herumklickt und damit mehrere Actions auf einmal auslöst? Etwa fetchUsers, fetchPosts, fetchPost und fetchComments, in der Reihenfolge? In dem Fall könnte man fetchUsers und fetchPosts canceln.

        Das ist eine gute Idee, mal über solch wild gewordene User nachzudenken. Aber eigentlich kann das bei mir nicht passieren, weil ich mit dem Start eines Requests ein Overlay mit Pausenkringel über die Seite lege. Da kann man draufklicken bis zum Umfallen der next-Handler es wieder deaktiviert. Auch ein Verlagern in die Request-Middleware (HttpInterceptor bei Angular) kann man machen, dann muss sich die Businesslogik nicht darum kümmern.

        Ohne Overlay würden die Responses irgendwann eintrudeln und tun was sie tun sollen. Wenn eine dabei ein Routing ausführt, dann kommen die anderen Antworten eben nicht mehr bei den nun nicht mehr vorhandenen Komponenten an. Gegebenenfalls müssen die sich zu ihrem Lebenszeitende am Observable abmelden. - Abmelden bei Promises? Ist mir noch nicht übern Weg gelaufen. Ist das da überhaupt vorgesehen?

        dedlfix.

        1. Hallo dedlfix,

          Das ist eine gute Idee, mal über solch wild gewordene User nachzudenken. Aber eigentlich kann das bei mir nicht passieren, weil ich mit dem Start eines Requests ein Overlay mit Pausenkringel über die Seite lege.

          Ich glaube, das finde ich kein gutes Verhalten. Das führt bei schlechter Verbindung dazu, dass der User nur der Kringel sieht. Fänd ich jetzt nicht so richtig cool 😉

          Da kann man draufklicken bis zum Umfallen der next-Handler es wieder deaktiviert. Auch ein Verlagern in die Request-Middleware (HttpInterceptor bei Angular) kann man machen, dann muss sich die Businesslogik nicht darum kümmern.

          Klar, man kann viel machen, aber was ist hier die best practice?

          Abmelden bei Promises? Ist mir noch nicht übern Weg gelaufen. Ist das da überhaupt vorgesehen?

          Nicht soweit mir bekannt, nein - dafür kann man sie ja canceln. Edit: Äh, natürlich nur, wenn man sowas wie Bluebird oder so einsetzt.

          LG,
          CK

          1. Tach!

            Das ist eine gute Idee, mal über solch wild gewordene User nachzudenken. Aber eigentlich kann das bei mir nicht passieren, weil ich mit dem Start eines Requests ein Overlay mit Pausenkringel über die Seite lege.

            Ich glaube, das finde ich kein gutes Verhalten. Das führt bei schlechter Verbindung dazu, dass der User nur der Kringel sieht. Fänd ich jetzt nicht so richtig cool 😉

            Naja, was soll er auch sonst machen als auf das Timeout zu warten? Und was soll die Anwendung in dem Fall machen? Den Request abbrechen, der schon halb bearbeitet ist? Noch mehr Requests über die eh schon langsame Leitung draufzuwerfen macht es ja auch nicht besser. Oder anders gefragt, wie gehst du mit solchen überall klickenden Anwendern für gewöhnlich um, ohne dass die Datenkonsistenz leidet?

            Da kann man draufklicken bis zum Umfallen der next-Handler es wieder deaktiviert. Auch ein Verlagern in die Request-Middleware (HttpInterceptor bei Angular) kann man machen, dann muss sich die Businesslogik nicht darum kümmern.

            Klar, man kann viel machen, aber was ist hier die best practice?

            Zumindest kennen die Anwender solche Wartezeiten seit es Sanduhren dafür gibt.

            dedlfix.

            1. Hallo dedlfix,

              Oder anders gefragt, wie gehst du mit solchen überall klickenden Anwendern für gewöhnlich um, ohne dass die Datenkonsistenz leidet?

              Ich hatte das Problem bisher nicht, weil ich bisher keine SPAs geschrieben habe. Deshalb ja auch mein bohren nach den best practices 😀

              LG,
              CK

      2. Würdest rethrows empfehlen?

        Ja, das ist das Standardvorgehen beim Arbeiten mit Promises, also mache ich es auch im Zusammenhang mit redux-thunk.

        Dieses "Rethrowing" kann man natürlich kapseln, sodass man es nicht ausdrücklich schreiben muss und nicht vergessen kann. Wie würdest du das implementieren?

        Ich habe eine Helferfunktion, um Requests zu machen:

        const cancelledError = throw new Error('Cancelled request');
        
        function request(options) {
          return (dispatch, getState) => {
            let cancelled = false;
            const promise = axios({
              method: options.method,
              url: options.url,
              data: options.body,
              headers:
                options.authenticated ?
                { 'X-Access-Token': getState().session.token } :
                {}
            });
            promise.cancel = () => {
              cancelled = true;
            };
            dispatch(options.pendingAction(promise));
            return promise.then(
              (result) => {
                if (cancelled) {
                  throw cancelledError;
                }
                dispatch(options.successAction(result));
                return result;
              },
              (error) => {
                if (cancelled) {
                  throw cancelledError;
                }
                dispatch(options.errorAction(result));
                throw error;
              },
            );
          };
        }
        

        Dann:

        function createFoo(foo) {
          return function(dispatch) {
            return dispatch(
              request({
                method: 'POST',
                url: '/foos',
                body: foo,
                authenticated: true,
                pendingAction: createFooPending,
                successAction: createFooSuccess,
                errorAction: createFooError,
              })
            );
          };
        }
        

        (ungetestet, soll nur das Prinzip )

        Wie gehst du denn damit um, dass man ggfls Requests canceln will, etwa weil der User wild herumklickt und damit mehrere Actions auf einmal auslöst?

        Dafür habe ich eine Lösung gefunden, aber keine schöne.

        Ich habe einen "cancelable promise" gebaut (siehe oben - da gibts auch bessere Lösungen). Dieser wird als Payload einer Pending-Action dispatcht. Der Reducer speichert ihn dann im Redux-Store. Beim nächsten Aufruf des Action Creators wird der laufende Promise mit getState() aus dem Store geholt. Gibt es einen, so wird er mit .cancel() abgebrochen. Bei Success oder Error wird der Promise aus dem Store entfernt.

        Du meinst, dass ich den Action Creator weiter zerlege in kleinere, die nacheinander dispatcht werden und die Komponente dann nur den „Haupt-Action-Creator“ bekommt?

        Genau!

        Bernd das Brot

  4. Ich könnte natürlich auch meine Redirections und dergleichen in die Actions verlagern und damit die Business-Logik komplett raushalten aus meinen Components… leider fehlt mir da noch etwas die Erfahrung um sagen zu können, was denn der Weg mit den wenigsten Nachteilen ist, um mit diesem Problem umzugehen.

    Das fände ich die schönste Lösung, für Redirections gibt es ja auch schon fertige Router-Komponenten. Eine gängige Praxis, inbsesondere für React in Verbindung mit Redux, ist es Komponenten in präsentationsbezogene und verhaltensbezogene Komponenten zu unterteilen. Aktuell wird dieses best practice aber wieder überdacht, weil das zu verschachtelten Komponenten-Strukturen führt. Das React-Team arbeitet deshalb gerade an Hooks, die Anwendungsfälle überschneiden sich in großen Teilen mit denen von Redux und meiner Anschätzung nach werden Hooks Redux vermutlich bald einiges an Wasser abgraben. Die Schnittstelle ist allerdings noch experimentell.

  5. Moin zusammen,

    danke für die Anregungen. Ich habe jetzt die Business-Logik in meine action generators verlegt.

    LG,
    CK