/** * Allgemeiner Lade-Hook fuer API-Ressourcen. * * Er folgt demselben Muster wie useSystemHealth: Solange kein echtes Ergebnis * vorliegt, bleibt der Zustand ausdruecklich „laedt" oder „Fehler" — niemals ein * leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden liesse * (PROMPT.md §139). * * Der Hook ersetzt die Wiederholung derselben dreissig Zeilen in jeder Seite. * Genau deshalb steht er hier und nicht in einer der Seiten: Ein zweiter Ort mit * eigener Fehlerbehandlung waere ein zweiter Ort, an dem sie fehlen kann. */ import { useCallback, useEffect, useState } from 'react'; import { ApiError } from './client'; /** Ladezustand einer Ressource. */ export type ResourceLoadState = 'loading' | 'loaded' | 'failed'; /** Ergebnis des Lade-Hooks. */ export interface UseApiResourceResult { /** Aktueller Ladezustand. */ readonly loadState: ResourceLoadState; /** Geladene Daten; null, solange keine vorliegen. */ readonly data: TPayload | null; /** Aufgetretener Fehler; null, wenn keiner vorliegt. */ readonly loadError: ApiError | null; /** Laedt die Ressource erneut. */ readonly reload: () => void; } /** * Laedt eine Ressource und haelt ihren Zustand. * * @param loadResource Ladefunktion; sie erhaelt ein Abbruchsignal. * @param dependencyKey Aendert sich dieser Wert, wird neu geladen. Ein einzelner * Schluessel statt eines Abhaengigkeitsarrays: Ein Array mit wechselnder Laenge * ist in React ein Fehler, und ein Objekt als Abhaengigkeit laedt bei jedem * Rendern neu. */ export function useApiResource( loadResource: (abortSignal: AbortSignal) => Promise, dependencyKey = '', ): UseApiResourceResult { // reloadCounter erzwingt einen erneuten Lauf des Effekts bei manuellem Neuladen. const [reloadCounter, setReloadCounter] = useState(0); // Das Ergebnis traegt den Schluessel, unter dem es entstanden ist. Daraus // laesst sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen: // Passt der Schluessel nicht zum aktuellen, laeuft die Anfrage noch. Ein // setState im Effektkoerper loeste dagegen eine zweite Renderrunde aus, // bevor ueberhaupt etwas geladen wurde. const [loadResult, setLoadResult] = useState<{ key: string; data: TPayload | null; error: ApiError | null; } | null>(null); const effectiveKey = `${dependencyKey}#${String(reloadCounter)}`; const reload = useCallback(() => { setReloadCounter((previousCounter) => previousCounter + 1); }, []); useEffect(() => { const abortController = new AbortController(); async function loadFromApi(): Promise { try { const loadedPayload = await loadResource(abortController.signal); setLoadResult({ key: effectiveKey, data: loadedPayload, error: null }); } catch (caughtError) { // Ein Abbruch ist kein Fehler, sondern Folge des Aufraeumens. if (caughtError instanceof DOMException && caughtError.name === 'AbortError') { return; } setLoadResult({ key: effectiveKey, data: null, error: caughtError instanceof ApiError ? caughtError : new ApiError({ code: 'UNEXPECTED_ERROR', message: 'Die Anfrage ist unerwartet fehlgeschlagen.', statusCode: 0, requestId: '', }), }); } } void loadFromApi(); return () => abortController.abort(); // loadResource bewusst nicht in den Abhaengigkeiten: Eine bei jedem Rendern // neu gebildete Funktion loeste sonst eine Endlosschleife aus. Der // effectiveKey steuert das Neuladen ausdruecklich. // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveKey]); // Solange kein Ergebnis zum aktuellen Schluessel vorliegt, wird geladen. Die // vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel laesst die // Tabelle also nicht aufblitzen. if (loadResult === null || loadResult.key !== effectiveKey) { return { loadState: 'loading', data: loadResult?.data ?? null, loadError: null, reload, }; } if (loadResult.error !== null) { return { loadState: 'failed', data: null, loadError: loadResult.error, reload }; } return { loadState: 'loaded', data: loadResult.data, loadError: null, reload }; }