Wenn imperativ besser ist als deklarativ

Wenn man vollständig an einem deklarativen Ansatz festhält, der die Aktualisierung von Daten und die Anzeige der Benutzeroberfläche trennt, gibt es bestimmte Situationen, in denen der Fluss der Komponentenlogik nicht mehr linear ist und somit schwer zu lesen und zu verstehen ist. In diesem Artikel werden wir versuchen, eine deklarative Dialog-API in eine imperative API zu verpacken, die Entwicklern Flexibilität bietet, während der Kontrollfluss linear und leicht nachvollziehbar bleibt.

Der Aufstieg Reacts

Eines der größten Verkaufsargumente von React im Vergleich zu jQuery & Co ist der deklarative Ansatz. Entwickler müssen nicht mehr darüber nachdenken, wie sie ihre UI von einem Zustand in einen anderen bringen. Sie aktualisieren einfach die zugrunde liegenden Daten und die Benutzeroberfläche folgt auf magische Weise:

function CounterButton() {
  const [currentCount, setCurrentCount] = useState(0);

  const nextCount = currentCount + 1;

  return (
    <div>
      <p>Current count: {currentCount}.</p>
      <button onClick={() => setCurrentCount(currentCount + 1)}>
        After clicking, the value will be {nextCount}.
      </button>
    </div>
  );
}

In diesem Beispiel müssen wir uns nicht darum kümmern, an wie vielen Stellen der aktuelle Zählerstand in der Benutzeroberfläche verwendet wird oder wo er benötigt wird, um abgeleitete Werte (wie nextCount) zu berechnen. Alles, was unsere Komponente tut, ist zu beschreiben (oder zu deklarieren?), wie die Benutzeroberfläche aussehen soll, wenn ein bestimmter Zustand vorliegt. Wenn wir die zugrunde liegenden Daten ändern, kümmert sich React um den Übergang von einer UI zur nächsten. Wenn Du daran interessiert bist, wie React das bewerkstelligt, kannst Du dir die offizielle Dokumententation über den Reconciliation-Prozess ansehen.

Meistens macht dieser deklarative Ansatz die Entwicklung für uns einfacher. Aber es gibt einige Fälle, in denen er mühsam werden kann.

Wenn Deklarativität zur Belastung wird

Beginnen wir mit einer Basiskomponente, die eine Liste von Benutzern anzeigt:

type User = {
  id: number;
  name: string;
};

function App() {
  const [users, setUsers] = useState<User[]>([
    { id: 1, name: "peter" },
    { id: 2, name: "tony" },
  ]);

  return (
    <div>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            {user.name} <button>delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Jetzt wollen wir Benutzer löschen, indem wir auf die Schaltfläche "Delete" klicken. Das Löschen von Daten ist jedoch oft ein unumkehrbarer Vorgang. Um sicherzugehen, wollen wir also beim Anklicken der Schaltfläche einen Bestätigungsdialog anzeigen, in dem wir explizit bestätigen müssen, dass wir löschen wollen. Dazu haben wir eine kleine Dialogkomponente gebaut:

import Dialog from "@reach/dialog";
import { ReactNode } from "react";

function ConfirmationDialog(props: {
  ariaLabel: string;
  children: ReactNode;
  onCancel: () => void;
  onConfirm: () => void;
}) {
  return (
    <Dialog onDismiss={props.onCancel} aria-label={props.ariaLabel}>
      <p>{props.children}</p>
      <button onClick={props.onCancel}>Close</button>
      <button onClick={props.onConfirm}>Confirm</button>
    </Dialog>
  );
}

Hier nutzen wir die hervorragende Dialog-Komponente von ReachUI, um sicherzustellen, dass unser Dialog für alle Benutzer zugänglich ist. Diese Komponente ist noch vollständig deklarativ: Wenn sie gerendert wird, ist der Dialog sichtbar. Keine imperativen open oder close Aufrufe.

Jetzt fügen wir unserer App einige neue Zustände und Funktionen hinzu, um die Verwendung unseres Bestätigungsdialogs vorzubereiten:

function App() {
  // ...

  // Wir brauchen eine neue Zustandsvariable, um zu wissen,
  // welcher Nutzer gerade gelöscht werden soll.
  const [userToBeDeleted, setUserToBeDeleted] = useState<User | null>(null);

  // Wenn wir "delete" klicken, legen wir den Nutzer erstmal im Zustand ab.
  function handleDeleteClick(user: User) {
    setUserToBeDeleted(user);
  }

  // Erst nach der Bestätigung löschen wir den Nutzer wirklich.
  function deleteUser(user: User) {
    // Rufe hier eine Backend-API für's Löschen auf
    console.log(user);

    // Wir müssen den Zustand wieder auf null setzen,
    // um den Dialog zu schließen.
    setUserToBeDeleted(null);
  }

  // ...
}

Wir können bereits sehen, dass unser Code irgendwie unzusammenhängend wird. Der Klick auf den Löschen-Button setzt nur den Status und sonst nichts. Um herauszufinden, was genau nach dem Löschen-Klick passiert, müssen wir uns ansehen, wie dieser Zustand verwendet wird:

default function App() {
  // ...

  const [userToBeDeleted, setUserToBeDeleted] = useState<User | null>(null);

  function handleDeleteClick(user: User) {
    setUserToBeDeleted(user);
  }

  function deleteUser(user: User) {
    console.log(user);
    setUserToBeDeleted(null);
  }

  return (
    <div>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            {user.name}{" "}
            <button onClick={() => handleDeleteClick(user)}>delete</button>
          </li>
        ))}
      </ul>
      {userToBeDeleted && (
        <ConfirmationDialog
          ariaLabel="Confirm user deletion."
          onCancel={() => setUserToBeDeleted(null)}
          onConfirm={() => deleteUser(userToBeDeleted)}
        >
          Should the user "{userToBeDeleted.name}" really be deleted?
        </ConfirmationDialog>
      )}
    </div>
  );
}

Wenn wir versuchen, nachzuvollziehen, was passiert, wenn wir auf die Schaltfläche "Delete" klicken, müssen wir zunächst in die Funktion handleDeleteClick springen. Diese setzt den angeklickten Benutzer in eine Zustandsvariable. Jetzt müssen wir wieder nach unten scrollen und suchen, wo wir diese Zustandsvariable verwenden und sehen, dass wir den Dialog rendern, wenn ein Benutzer in der Zustandsvariable vorhanden ist. Wenn wir auf "confirm" klicken, müssen wir noch einmal nach oben zur Funktion deleteUser scrollen, um zu sehen, dass wir eine API-Funktion aufrufen könnten, um den Benutzer wirklich zu löschen.

Ich hoffe, Du siehst, dass das nicht optimal ist. Durch das Festhalten am deklarativen Ansatz haben wir keinen verständlichen Fluss mehr, wenn wir versuchen, den Prozess dessen zu verfolgen, was nach einem Klick auf "delete" passiert.

Wie würde eine schönere API aussehen?

Lass uns versuchen, einen besseren Ansatz zu finden. Der Gesamtablauf, den wir abbilden wollen, ist der folgende:

  1. Benutzer klickt auf Löschen
  2. Aufforderung an den Benutzer, seine Aktion zu bestätigen
  3. Wenn nicht bestätigt, Abbruch
  4. Wenn bestätigt, lösche den Benutzer

Hier siehst Du, wie wir diesen Ablauf mit der nativen confirm-Funktion des Browsers implementieren könnten:

default function App() {
  // ...

  function handleDeleteClick(user: User) {
    const shouldBeDeleted = confirm(
      `Should the user "${user.name}" really be deleted?`
    );
    if (!shouldBeDeleted) return;

    // Make API call here
    console.log(user);
  }

  // ...
}

Das ist schon gleich viel linearer. Wenn man sich diese Funktion ansieht, kann man erkennen, was ihr Zweck ist. Es gibt jedoch ein Problem: Durch die Verwendung des nativen confirm verlieren wir die Möglichkeit, benutzerdefiniertes Styling auf diesen Dialog anzuwenden. Beachte außerdem, dass confirm synchron ist. Solange der Dialog geöffnet ist, friert das Browserfenster ein. Keine Aktualisierungen, keine Animationen, keine Timer.

Stellen wir uns eine API vor, mit der wir den Inhalt des Dialogs vollständig anpassen können, während der Browser weiterlaufen darf:

default function App() {
  // ...

  async function handleDeleteClick(user: User) {
    const shouldBeDeleted = await openModal<boolean>((close) => (
      <ConfirmationDialog
        ariaLabel="Confirm user deletion."
        onCancel={() => close(false)}
        onConfirm={() => close(true)}
      >
        Should the user "{user.name}" really be deleted?
      </ConfirmationDialog>
    ));
    if (!shouldBeDeleted) return;

    // Make API call here
    console.log(user);
  }

  // ...
}

Das erhöht natürlich die Größe und Komplexität unseres Event-Handlers ein wenig. Meiner Meinung nach ist das aber immer noch lesbar, während der lineare Ablauf der Löschaktion erhalten bleibt. Obendrein gewinnen wir eine vollständige Anpassbarkeit des Dialogs.

Interessiert an React patterns? Folge mir auf Twitter, um keine neuen Inhalte zu verpassen.

Jetzt müssen wir diese openDialog-Funktion noch irgendwie implementieren. Wir kennen immerhin schon die Schnittstelle und können diese mit etwas Leben füllen:

/**
 * Eine Funktion, die ein Ergebnis mit einem variablen Typ erwartet
 * und nichts zurück gibt.
 * Sie soll unseren Dialog schließen und das Ergebnis an den Aufrufer
 * von `openModal` zurückgeben.
 */
type CloseModal<ResultType> = (result: ResultType) => void;

/**
 * Eine Funktion, die die Oberfläche für den Dialog erzeugt. Sie erhält
 * die Schließen-Funktion als Argument und gibt einen `ReactNode` zurück,
 * den wir dann später als Dialog darstellen können.
 */
type ModalFactory<ResultType> = (close: CloseModal<ResultType>) => ReactNode;

/**
 * openModal erhält eine ModalFactory und gibt ein Promise zurück.
 * Das Promise wird dann das Ergebnis zurückgeben.
 */
function openModal<ResultType>(
  modalFactory: ModalFactory<ResultType>
): Promise<ResultType> {
  // Erzeuge ein neues Promise, das wir kontrollieren können.
  return new Promise<ResultType>((resolve) => {
    function closeModal(result: ResultType) {
      // Indem wir resolve aufrufen, leiten wir `result`
      // als Ergebnis des Promise weiter.
      resolve(result);
    }

    // Erzeuge das JSX für unseren Dialog.
    const modal = modalFactory(closeModal);

    // ...
  });
}

Jetzt haben wir eine Funktion, die etwas JSX erzeugt. Um dieses anzuzeigen, müssen wir das JSX irgendwo speichern und es von einer React-Komponente zurückgeben. Klingt für mich nach einer Custom Hook:

function useModal() {
  // Der ReactNode muss irgendwo abgelegt werden
  const [modalNode, setModalNode] = useState<ReactNode>(null);

  function openModal<ModalResult>(modalFactory: ModalFactory<ModalResult>) {
    return new Promise<ModalResult>((resolve) => {
      function close(value: ModalResult) {
        resolve(value);

        // Wenn der Dialog geschlossen werden soll, legen
        // wir "null" in den Zustand
        setModalNode(null);
      }

      // Um den Dialog zu öffnen, legen wir das JSX in den State
      setModalNode(modalFactory(close));
    });
  }

  // Wir geben den ReactNode des Dialogs und die openModal Funktion zurück.
  return [modalNode, openModal] as const;
}

Hier haben wir die Funktion openModal in eine Custom Hook verpackt. Jetzt können wir den erstellten Dialog in einem Zustand speichern und diesen Zustand darstellen, bis jemand die Funktion close aufruft. Beim Schließen lösen wir das Promise auf und entfernen das Modal aus dem Zustand, um es wieder auszublenden.

Fügen wir alles zusammen, um unsere useModal-Hook in Aktion zu sehen:

function App() {
  const [users, setUsers] = useState<User[]>([
    { id: 1, name: "peter" },
    { id: 2, name: "tony" },
  ]);

  const [modalNode, openModal] = useModal();

  async function handleDeleteClick(user: User) {
    const shouldBeDeleted = await openModal<boolean>((close) => (
      <ConfirmationDialog
        ariaLabel="Confirm user deletion."
        onCancel={() => close(false)}
        onConfirm={() => close(true)}
      >
        Should the user "{user.name}" really be deleted?
      </ConfirmationDialog>
    ));

    if (!shouldBeDeleted) return;

    // REST-API Aufrufe zum Löschen
    console.log(user);
  }

  return (
    <div>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            {user.name}{" "}
            <button onClick={() => handleDeleteClick(user)}>delete</button>
          </li>
        ))}
      </ul>
      {modalNode}
    </div>
  );
}

Beachte, dass wir das aktuell angezeigte Modal von unserer useModal-Hook zurückgeben. Diese Variable wird mit einem ReactNode befüllt sein, wenn ein Modal geöffnet ist, und mit null, wenn es geschlossen ist. Wir müssen das JSX, das von unserer Komponente zurückgegeben wird, so anpassen, dass es den modalNode enthält, sodass er gerendert wird, wenn wir ein offenes Modal benötigen.

Durch die Verwendung dieser neuen Custom Hook haben wir eine imperative Api eingeführt, um Informationen vom Benutzer abzufragen. Beachte, dass wir unter der Haube immer noch den deklarativen Rendering-Ansatz von React verwenden: Wir aktualisieren Daten (das JSX mit dem Dialog) und React kümmert sich um die UI. Wir haben diese deklarativen Details nur in einer imperativen API versteckt, um die Nutzung zu vereinfachen.

In unserem Fall benötigen wir eine einfache Bestätigung (true oder false). Wir könnten jedoch auch komplexere Informationen abfragen, z. B. den Benutzer aus verschiedenen Login-Providern (z. B. twitter, github, facebook) auswählen lassen oder dem Benutzer eine Tabelle mit Benutzerentitäten zeigen und ihn mehr als eine auswählen lassen.

Letztendlich müssen wir uns bewusst sein, dass einige Prozesse grundlegend imperativ sind: "Benutzer, wähle eine Option!", "Benutzer, bist Du Dir sicher, dass Du fortfahren möchtest?". Indem wir es Entwicklern ermöglichen, diese Abfragen imperativ in Funktionen einzubetten, ermöglichen wir einen lineareren Kontrollfluss und machen Komponenten einfacher zu schreiben und zu verstehen.

Wenn Du mit unserer Custom Hook herumspielen möchtest, öffne diese codesandbox und probiere Sie aus.

All das ist nur möglich, weil React UI selbst als Daten behandelt, die in Variablen gespeichert oder von Funktionen zurückgegeben werden können - aber dazu mehr in einem der folgenden Artikel.