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.

TypVerwendungElementFehlerfallLoading
LayoutCardÄußerste Boundary einer SeiteLayoutCardLayoutCard mit IllustratedMessageLayoutCard mit Sections mit mehrzeiligen Skeletons
LayoutCardFragmentFür Dashboards mit ungewisser Anzahl und Breite der KachelnFragmentLayoutCard mit IllustratedMessageLayoutCard mit Sections mit mehrzeiligen Skeletons
SectionsFragmentÄußerste Boundary in TabsFragmentIllustratedMessageSections mit mehrzeiligen Skeletons
SectionErsetzt normale SectionsSectionFehler SectionMehrzeilige Skeletons
ContentFragmentFür mehrzeilige InhalteFragmentFehler SectionMehrzeilige Skeletons
FragmentFür Inhalte die im Lade / Fehlerzustand nicht angezeigt werdenFragmentNichtsNichts
ModalErsetzt normale ModalsModalIllustratedMessageLoadingView der Flow Component
ContextMenuErsetzt normale ContextMenusContextMenuMenuItems mit Fehler TextMenuItems mit Skeleton
ChartFragmentFür CartesianChartsFragmentCartesianChart mit IllustratedMessageCartesianChart mit LoadingSpinner
FieldFragmentFür Form Fields mit nachgeladenen InhaltenFragmentFehler TextTextField
AvatarFür AvatareAvatarAvatar mit Fehler IconAvatar mit Skeleton
LinkFür LinksLinkFehler TextEinzeiliges Skeleton
TextFür TexteTextFehler TextEinzeiliges Skeleton
StringFür einzeilige InhalteFragmentFehler TextEinzeiliges Skeleton

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:CronjobProxy;
  controller?: OverlayController;
}

export const RenameCronjobModal: FC<Props> = (props) => (
  <WithBoundaries.Modal controller={props.controller}>
    {() => {
      const { cronjobProxy } = asProxyProps(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 { customerProxy, asBadge } = asProxyProps(props, ["customer"]);
      const isBankrupt = customerProxy.getDetailed().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)

export const ActionsContextMenu: FC<Props> = (props) => {
  const { cronjobProxy } = asProxyProps(props);
  const deleteModalController = useOverlayController("Modal");

  return (
    <>
      <WithBoundaries.ContextMenu placement="bottom end">
        {() => {
          const cronjob = cronjobProxy.getCommon().use();
          return <DeleteMenuItem onAction={() => deleteModalController.open()} />;
        }}
      </WithBoundaries.ContextMenu>

      <DeleteCronjobModal
        controller={deleteModalController}
        cronjob={cronjobProxy}
      />
    </>
  );
};

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.