Boundaries

Error- und Loading Boundaries sind ein zentrales Werkzeug, um Ladezustände anzuzeigen und Fehler im UI gezielt abzufangen. Sie verhindern, dass Fehler unkontrolliert die gesamte Anwendung zum Absturz bringen, und bieten die Möglichkeit, Usern eine verständliche Rückmeldung zu geben. Gleichzeitig unterstützen sie Entwicklern durch Logging und Monitoring bei der Fehleranalyse.

Da jede Anwendung unterschiedliche Anforderungen hat, gibt es kein universelles Schema, wo und wie Boundaries am besten platziert werden. Die folgenden Best practices sollen bei der Orientierung und Entscheidungsfindung helfen.

Technische Umsetzung

Bei der Platzierung der Boundaries empfiehlt es sich, vom Groben ins Detail vorzugehen. Entscheidend ist eine gute Balance: Zu viele kleine Boundaries erhöhen die Komplexität, zu wenige gefährden hingegen die Stabilität großer Teile der Anwendung.

Boundaries wie "LayoutCard", "Section" rendern das jeweilige Element, Boundaries mit dem Zusatz "Fragment" nutzen die Fehler und Ladezustände des jeweiligen Elements, rendern am Ende aber nur ein Fragment

TypVerwendungFehlerfallLoading
LayoutCard / LayoutCardFragmentÄußerste Boundary einer Seite
SectionsFragmentÄußerste Boundary in Tabs
SectionErsetzt normale Sections
ContentFragmentFür mehrzeilige Inhalte
FragmentFür Inhalte die im Lade / Fehlerzustand nicht angezeigt werdenKein FallbackKein Fallback
ModalErsetzt normale Modals
ContextMenuErsetzt normale ContextMenus
MenuItemFragmentFür MenuItemsKein FallbackKeine Loading Boundary
ChartFragmentFür CartesianCharts
FieldFragmentFür Form Fields mit nachgeladenen Inhalten
AvatarFür Avatare
Text / StringFür Texte

Inhalte einer LayoutCard (WithBoundaries.LayoutCard)

Sind Funktionen oder Informationen einer LayoutCard essenziell für die Nutzung der Seite, sollte die gesamte LayoutCard mit WithBoundaries.LayoutCard umschlossen sein. Dabei wird die Anzahl der geladenen Sections als sectionsCount mitgegeben, um die LoadingView passend darzustellen.

Listenseite

Eine Listenseite enthält in der Regel eine einzelne Section. Der sectionsCount beträgt hier daher 1. Der Count der Liste wird zuerst geladen, damit die gesamte Liste im Fehlerfall in die ErrorView wechseln kann.

export const CronjobsPage: FC = () => (
  <WithBoundaries.LayoutCard suspenseFallbackProps={{ sectionsCount: 1 }}>
    {() => {
      const { projectId } = usePathParams("projectId");
      const projectGhost = ProjectGhost.ofId(projectId);
      const cronjobCount = projectGhost.cronjobs.getTotalCount().use();

      return (
        <>
          <ProjectDeactivatedAlert project={projectGhost} />
          <WithBoundaries.Section>
            {backupCount === 0 ? <CronjobIllustratedMessage project={projectGhost} /> : <CronjobList project={projectGhost} />}
          </WithBoundaries.Section>
        </>
      );
    }}
  </WithBoundaries.LayoutCard>
);

Detailseite

Der sectionsCount sollte der erwarteten Anzahl an Sections entsprechen. So bleibt das Layout während des Ladevorgangs stabil. Das Hauptelement der Seite wird zuerst geladen, damit im Fehlerfall für die gesamte Seite eine ErrorView angezeigt werden kann.

export const CronjobPage: FC = () => (
  <WithBoundaries.LayoutCard suspenseFallbackProps={{ sectionsCount: 2 }}>
    {() => {
      const { cronjobId } = usePathParams("cronjobId");
      const cronjob = CronjobGhost.ofId(cronjobId).getCommon().use();

      return (
        <>
          <ProjectDeactivatedAlert project={cronjob.project} />
          <GeneralSection cronjob={cronjob} />
          <IntervalSection cronjob={cronjob} />
        </>
      );
    }}
  </WithBoundaries.LayoutCard>
);

Tab (WithBoundaries.SectionsFragment)

Manche Seiten verteilen Inhalte auf mehrere Tabs. In solchen Fällen kann es sinnvoll sein, Tabs separat abzusichern. Dafür eignet sich WithBoundaries.SectionsFragment.

export const CronjobTabs: FC = () => (
  <LayoutCard>
    <Tabs>
      <Tab>
        <TabTitle>Allgemein</TabTitle>
        <WithBoundaries.SectionsFragment suspenseFallbackProps={{ sectionsCount: 3 }}>
          {() => {
            const { cronjobId } = usePathParams("cronjobId");
            const cronjob = CronjobGhost.ofId(cronjobId).getCommon().use();

            return (
              <>
                <ProjectDeactivatedAlert project={cronjob.project} />
                <GeneralSection cronjob={cronjob} />
                <IntervalSection cronjob={cronjob} />
              </>
            );
          }}
        </WithBoundaries.SectionsFragment>
      </Tab>
    </Tabs>
  </LayoutCard>
);

Modals (WithBoundaries.Modal)

Für Modals sollte immer ein WithBoundaries.Modal verwendet werden. Dies verhindert auch das Nachladen von Daten innerhalb des Modals, bevor das Modal geöffnet wird.

Inhalte von Forms in Modals

Eine teilweise geladene oder fehlerhafte Form untergräbt das Vertrauen der User und kann zu unvollständigen oder inkonsistenten Daten führen. Sobald innerhalb der Form ein Fehler auftritt, fällt automatisch das ganze Modal in eine ErrorView, auch wenn es darin noch Boundaries gibt.

interface Props {
  cronjob:CronjobGhost;
  controller?: OverlayController;
}

export const RenameCronjobModal: FC<Props> = (props) => (
  <WithBoundaries.Modal controller={props.controller}>
    {() => {
      const { cronjobGhost } = asGhostProps(props, ["cronjob"]);
      const controller = useOverlayController("Modal");
      const form = useForm();
      const handleOnSubmit = async () => {
        ...
        controller.close();
      };

      return (
        <Form form={form} onSubmit={handleOnSubmit}>
          ...
        </Form>
      );
    }}
  </WithBoundaries.Modal>
);

Inhalte einzelner Sections (WithBoundaries.Section)

Eine Section kann ergänzende Informationen oder Funktionen aus einem anderen Service enthalten, die das primäre Nutzererlebnis der Seite nicht einschränken. In solchen Fällen lohnt sich eine Abgrenzung über WithBoundaries.Section. So bleiben Fehler auf einen kleinen Bereich beschränkt, ohne die gesamte Seite zu gefährden.

export const AppInstallationSection: FC<Props> = (props) => (
  <WithBoundaries.Section>
    {() => {
      const { cronjobGhost } = asGhostProps(props);
      const appInstallation = cronjobGhost.getCommon().linkedAppInstallation.getCommon().use();
      const t = useTCronjob();

      return (
        <>...</>
      );
    }}
  </WithBoundaries.Section>
);

Sonderfall: Manche Sections werden nur unter bestimmten Bedingungen angezeigt. In diesen Situationen soll keine eigene LoadingView erzeugt werden, damit Nutzer kein Ladeverhalten sehen, das später nicht zu einem sichtbaren Ergebnis führt. Für solche Fälle eignet sich WithBoundaries.Fragment, da es die Section schützt, ohne einen sichtbaren Ladezustand zu erzeugen.

export const AppInstallationSection: FC<Props> = (props) => (
  <WithBoundaries.Fragment>
    {() => {
      const { cronjobGhost } = asGhostProps(props);
      const cronjob = cronjobGhost.getCommon().use();
      const t = useTCronjob();

      if(!cronjob.linkedAppInstallation){
        return null;
      }

      return (
        <Section>...</Section>
      );
    }}
  </WithBoundaries.Fragment>
);

Inhalte eines Diagramms (WithBoundaries.ChartFragment)

Diagramme beziehen in der Regel viele Daten aus externen Services und sind daher fehleranfällig. Sie sollten immer von einer Boundary umschlossen werden. Für das CartesianChart gibt es hierfür das WithBoundaries.ChartFragment.

<WithBoundaries.ChartFragment>
  <CartesianChart>...</CartesianChart>
</WithBoundaries.ChartFragment>

Inhalte einzelner kleinerer Elemente

Kleine Elemente wie Texte, Links, Badges, Actions oder ContextMenus werden oft dynamisch geladen, da sie das Nutzungserlebnis der Seite lediglich ergänzen. Diese sollten daher separat geschützt werden, um nicht die gesamte Seite zu blockieren. Diese Elemente besitzen oft keine eigene ErrorView und erscheinen im Fehlerfall nicht oder verbleiben in ihrem Ladezustand.

Texte (WithBoundaries.Text)

<LabeledValue>
  <Label>{t("projectShortId")}</Label>
  <WithBoundaries.Text>
    {() =>  projectGhost.getCommon().use().shortId }
  </WithBoundaries.Text>
</LabeledValue>

Zusätzlich gibt es Components die direkt auf ein Model angewendet werden können und eine eingebaute Boundary besitzen.

<ModelLink model={cronjob}>
  <ModelTitle model={cronjob} />
</ModelLink>

<ModelLabeledValue model={cronjob}/>

Alerts / Badges (WithBoundaries.Fragment)

Alerts und Badges ergänzen eine Seite oft nur um kleine Hinweise oder Statusinformationen. Sie werden in ein WithBoundaries.Fragment verpackt. So bleiben sie geschützt, ohne eine eigene LoadingView auszulösen.

export const CustomerBankruptAlert: FC<Props> = (props) => (
  <WithBoundaries.Fragment>
    {() => {
      const { customerGhost, asBadge } = asGhostProps(props, ["customer"]);
      const isBankrupt = customerGhost.getCommon().isBankrupt().use();

      if (!isBankrupt) {
        return null;
      }

      if (asBadge) {
        return <AlertBadge>...</AlertBadge>
      }

      return <Alert>...</Alert>;
    }}
  </WithBoundaries.Fragment>
);

Field (WithBoundaries.FieldFragment)

export const AppInstallationSelectField: FC<Props> = (props) => {
  const { name, label, projectGhost } = asGhostProps(props, ["project"]);

  return (
    <WithBoundaries.FieldFragment suspenseFallbackProps={{ label }}>
      {() => {
        const appInstallations = projectGhost.appInstallations.execute().use().items;

        return (
          <Field name={name}>
            <Select isDisabled={appInstallations.length === 0}>
              <Label>{label}</Label>
              {sortedAppInstallations.map((i) => (
                <Option key={i.id} value={i.id}>
                  {i.description}
                </Option>
              ))}
            </Select>
          </Field>
        );
      }}
    </WithBoundaries.FieldFragment>
  );
};

ContextMenu (WithBoundaries.ContextMenu)

Um zu verhindern, dass der Inhalt des ContextMenus springt, werden Elemente die nachgeladene Inhalte benötigen in ein WithBoundaries.MenuItemgepackt, diese Boundary enthält kein Suspense, damit das ganze ContextMenu im Ladezustand verbleibt.

export const ActionsContextMenu: FC<Props> = (props) => {
  const { appInstallationGhost } = asGhostProps(props);
  const deleteModalController = useOverlayController("Modal");
  const updateModalController = useOverlayController("Modal");

  return (
    <>
      <WithBoundaries.ContextMenu placement="bottom end">
        {() => {
          const { isBusy } = appInstallationGhost.getCommon().use();
          return (
            <>
              <DeleteMenuItem onAction={() => deleteModalController.open()} isDisabled={isBusy} />
              <WithBoundaries.MenuItemFragment>
                {() => {
                  const updateAvailable = appInstallationGhost.getCommon().appVersion.updateAvailable().use();
                  if (!updateAvailable) {
                    return null;
                  }
                  return (
                    <UpdateMenuItem onAction={() => updateModalController.open()}/>
                  );
                }}
              </WithBoundaries.MenuItemFragment>
            <>
          )
        }}
      </WithBoundaries.ContextMenu>

      <DeleteCronjobModal controller={deleteModalController} appInstallation={appInstallationGhost}/>
      <UpdateCronjobModal controller={updateModalController} appInstallation={appInstallationGhost}/>
    </>
  );
};

Weitere Empfehlungen

Ein durchdachtes Loading und ErrorBoundary Konzept endet nicht bei der technischen Umsetzung. Fehler sollten zuverlässig erfasst und an Monitoring-Systeme, Sentry oder Datadog weitergegeben werden, damit Probleme früh sichtbar werden. Manuelle und automatisierte Tests helfen dabei, die eigenen Fallbacks regelmäßig zu überprüfen und sicherzustellen, dass sie in allen relevanten Situationen greifen. Ebenso wichtig ist eine klare und konsistente Formulierung der Fehlermeldungen, damit User verstehen, was passiert und wie sie weitermachen können. Für konkrete Hinweise zur sprachlichen Ausgestaltung bietet die Guideline Fehlermeldungen weitere Orientierung. So entsteht ein stabiles und nachvollziehbares Verhalten, das sowohl die Entwicklung als auch die Nutzung der Anwendung unterstützt.

Boundaries