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
| Typ | Verwendung | Fehlerfall | Loading |
|---|---|---|---|
LayoutCard / LayoutCardFragment | Äußerste Boundary einer Seite | ||
SectionsFragment | Äußerste Boundary in Tabs | ||
Section | Ersetzt normale Sections | ||
ContentFragment | Für mehrzeilige Inhalte | ||
Fragment | Für Inhalte die im Lade / Fehlerzustand nicht angezeigt werden | Kein Fallback | Kein Fallback |
Modal | Ersetzt normale Modals | ||
ContextMenu | Ersetzt normale ContextMenus | ||
MenuItemFragment | Für MenuItems | Kein Fallback | Keine Loading Boundary |
ChartFragment | Für CartesianCharts | ||
FieldFragment | Für Form Fields mit nachgeladenen Inhalten | ||
Avatar | Für Avatare | ||
Text / String | Fü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.