Datenquellen
Kein Widget der Library lädt selbst Daten. Stattdessen bekommt es eine Datenquelle hereingereicht: eine Funktion, die ein React-Query-Ergebnis liefert.
dataSource: () => UseQueryResult<JsonObject, Error>
const useAkte = () => useFile(id); // die Quelle ist selbst ein Hook
<Field dataSource={useAkte} jsonPath="title.de" /> // ✅ benannte Quelle
<List dataSource={useFiles} columns={columns} /> // ✅ Hook direkt
<Field dataSource={useFile(id)} jsonPath="title.de" /> // ❌ Klammern zu früh: Ergebnis statt Funktion
<Field dataSource={() => useFile(id)} jsonPath="…" /> // ❌ Hook im Inline-Callback (siehe unten)
Weil in der Funktion Hooks stecken, gelten die Hook-Regeln: Das Widget ruft
dataSource() bei jedem Render unbedingt auf — die Funktion darf also nicht
bedingt einen Hook aufrufen und nicht zwischen zwei verschiedenen Hooks hin- und
herwechseln.
Wann () => — und wann der Hook direkt?
Der Grund für den Wrapper ist eine einzige Tatsache: das Widget ruft
dataSource() ohne Argumente auf. Alles, was der Hook braucht, muss die
Funktion also selbst mitbringen — entweder aus dem Closure oder indem sie es sich
per Hook holt.
Daraus folgt die ganze Regel:
| Der Hook … | Schreibweise | Warum |
|---|---|---|
braucht kein Pflichtargument (useFiles, useGroups, useUsers, useCurrentUser) | dataSource={useFiles} | erfüllt die Signatur () => … schon von sich aus |
braucht ein Argument (useFile(id), usePerson(id), useAnnotations(id)) | const useAkte = () => useFile(id) | die id gibt es nur bei dir — der Wrapper schließt sie ein |
const { fileId = '' } = useParams();
const useAkte = () => useFile(fileId); // ✅ id aus dem Closure
const useAkteFremd = () => useFile(fileId, 'Mandant'); // ✅ so pinnst du auch den Tenant
use-NamenIn der Funktion steckt ein Hook. Für ESLint ist sie damit ein Custom Hook,
und react-hooks/rules-of-hooks kennt genau zwei Arten, das falsch zu machen:
const akte = () => useFile(id); // ❌ Name ohne use-Präfix
// React Hook "useFile" is called in function "akte" that is neither a React
// function component nor a custom React Hook function.
<Field dataSource={() => useFile(id)} jsonPath="title.de" /> // ❌ inline in JSX
// React Hook "useFile" cannot be called inside a callback.
const useAkte = () => useFile(id); // ✅ benannt + use-Präfix
<Field dataSource={useAkte} jsonPath="title.de" />
Der zweite Fall ist der häufigere: eine Pfeilfunktion direkt in der Prop ist für
den Regelsatz ein Callback, egal was drinsteht. Beides läuft zur Laufzeit
trotzdem — es sind Lint-Fehler, keine Abstürze. Genau deshalb aber lohnt die
Zeile const useAkte = …: Sie ist der einzige Weg, der ohne Lint-Ausnahme
auskommt.
useFile oder filesStore.useOne?
Kurze Antwort: das ist dieselbe Funktion. filesStore ist nichts weiter als
ein Bündel aus den drei Files-Hooks:
// so ist filesStore in der Library definiert
export const filesStore: ObjectStore = {
useOne: useFile,
useList: useFiles,
useSave: useSaveFile,
};
filesStore.useOne(id) ruft also buchstäblich useFile(id) auf — es gibt
keinen Fall, in dem eine dataSource das eine bräuchte und das andere nicht. Es
sind zwei Namen für zwei Rollen:
| Konkreter Hook | Slot im ObjectStore | Wer ruft das auf |
|---|---|---|
useFile(id, tenant?) | useOne | du, in deiner dataSource |
useFiles(tenant?) | useList | du, direkt als dataSource |
useSaveFile(tenant?) | useSave | das Widget, beim Speichern |
- Konkrete Namen (
useFile,useFiles,useGroups, …) sind für deinen Code gedacht. Du weißt, dass es um Akten geht — also benenne es so. useOne/useList/useSavesind die generischen Slotnamen des InterfacesObjectStore. Sie existieren, damit ein Widget ein ganzes Bündel annehmen und daraus lesen und schreiben kann, ohne deine Domäne zu kennen.Assignmentmacht genau das: intern ruft esstore.useOne(id)undstore.useSave()auf.
Die Faustregel:
// dataSource → konkreter Hook, als benannte Quelle
const useAkte = () => useFile(fileId);
<Field dataSource={useAkte} jsonPath="title.de" />
// store-Prop → das ganze Bündel, das Widget greift selbst nach useOne/useSave
<Assignment type="group" id={fileId} store={filesStore} />
<Assignment type="group" id={fileId} /> {/* filesStore ist der Default */}
filesStore.useOne in eigenem Code zu schreiben lohnt nur, wenn der Store von
außen kommt — etwa in einer eigenen Komponente, die über einen beliebigen
ObjectStore funktionieren soll:
function Objektkopf({ store, id }: { store: ObjectStore; id: string }) {
const useObjekt = () => store.useOne(id); // hier ist useOne richtig:
return <Field dataSource={useObjekt} jsonPath="displayName" />; // die Domäne ist unbekannt
}
useList fordert das Interface, gelesen wird es (noch) nichtObjectStore verlangt alle drei Member, Assignment verwendet aber nur
useOne und useSave. Für ein eigenes Bündel musst du useList trotzdem
angeben, damit der Typ erfüllt ist.
Wer welche Quelle erwartet
| Widget | Prop | Erwartet | Pflicht |
|---|---|---|---|
Field | dataSource | () => UseQueryResult<JsonObject> | ja |
List | dataSource | () => UseQueryResult<any[]> | ja |
Badge | dataSource | () => UseQueryResult<JsonObject> | nein — ohne sie ist value der Text (Literal-Modus) |
FileManager | dataSource | () => UseQueryResult<JsonObject> — liest daraus $apiId | ja |
FileManager | documentsStore | Bündel useByFile + useUpload | nein — Default sind die Core-Hooks |
Assignment | dataSource | () => UseQueryResult<Group[]> (die Auswahlliste) | nein — Default useGroups/useUsers je type |
Assignment | store | ObjectStore (Lesen und Speichern) | nein — Default filesStore |
Submitter | dataSource | Query-Ergebnis oder direkt ein JsonObject | nein |
Einmal definieren, überall mitgeben
Der typische Fall: eine Detailansicht, in der mehrere Widgets dieselbe Akte anzeigen. Definiere die Quelle einmal und gib sie weiter:
function AktenDetail() {
const { fileId = '' } = useParams();
const useAkte = () => useFile(fileId); // ← die eine Quelle
return (
<>
<Field dataSource={useAkte} jsonPath="title.de" label="Betreff" />
<Field dataSource={useAkte} jsonPath="formattedNumber" label="Zahl" />
<Badge value="fileHandlingState" dataSource={useAkte} config={statusConfig} />
<FileManager dataSource={useAkte} />
</>
);
}
Jedes dieser vier Widgets ruft useAkte() in seinem eigenen Render auf, es
laufen also vier useQuery-Aufrufe. Weil alle denselben Query-Key erzeugen
(["file", tenant, id]), hängen sie am selben Cache-Eintrag von React Query:
Es entsteht nicht pro Widget ein eigener Ladevorgang, und alle sehen gleichzeitig
denselben Zustand (isPending, data, error).
Deshalb ist die naheliegende Sorge unbegründet — mehr Widgets an derselben Quelle
bedeuten nicht mehr Requests. Nachprüfen kannst du es im Netzwerk-Tab oder im
Mock-Modus an den 🔧-Zeilen in der Konsole.
Was du dabei sehen wirst: useFile läuft mit refetchOnMount: "always", holt
die Akte beim Öffnen der Ansicht also bewusst frisch — auch wenn schon etwas im
Cache liegt.
Variante: gebundene Komponente
Wenn dir das Wiederholen von dataSource={useAkte} zu viel ist, binde die Quelle
per Factory fest an eine Komponente. Das gehört auf Modulebene: die Factory
erzeugt einen neuen Komponententyp, und ein im Render erzeugter Typ ist für React
jedes Mal ein anderer — der Teilbaum wird neu aufgebaut und verliert seinen
State. useMemo drumherum hilft nicht zuverlässig; react-hooks/static-components
meldet den Fall deshalb als Fehler („Cannot create components during render").
Braucht die Quelle einen Parameter, holt sie ihn selbst — sie ist ein Hook, darf
also useParams oder Context benutzen:
import { createField, createList } from '@gentics/cockpit/widgets';
const AktenListe = createList(useFiles); // Quelle ohne Parameter
function useAkteAusRoute() { // Quelle mit Parameter aus der Route
const { fileId = '' } = useParams();
return useFile(fileId);
}
const AktenFeld = createField(useAkteAusRoute);
function AktenDetail() {
return (
<>
<AktenFeld jsonPath="title.de" label="Betreff" />
<AktenFeld jsonPath="formattedNumber" label="Zahl" />
</>
);
}
Damit useParams greift, muss die Komponente wie gewohnt innerhalb des Routers
hängen — die Quelle läuft im Render des Widgets, nicht im Modul-Scope.
Kommt die id dagegen von oben als Prop, passt die Factory nicht: dann bleibt es
bei dataSource={useAkte} wie im Abschnitt darüber.
Die gebundenen Komponenten nehmen alle Props des Originals außer dataSource —
die Typen dazu heißen BoundFieldProps und BoundListProps.
Statische Objekte: staticSource
Liegen die Daten schon vor, fehlt aber ein Query-Hook, wickelt staticSource
das Objekt in eine passende Quelle:
import { staticSource } from '@gentics/cockpit/widgets';
<Field dataSource={staticSource({ user: { name: 'Ada' } })} jsonPath="user.name" />
Zwei Details:
-
staticSource(undefined)bedeutet „lädt noch" (isPending: true) und ergibt ein Skeleton. Das ist praktisch, wenn du die Daten selbst lädst:const { data } = useFile(fileId);<Field dataSource={staticSource(data)} jsonPath="title.de" /> // Skeleton bis data da ist -
Fehler transportiert
staticSourcenicht. Schlägt dein eigener Ladevorgang fehl, bleibt es beim Skeleton bzw. beim Platzhalter — Fehlerbehandlung machst du dann selbst drumherum.
Randfälle
Die Id ist noch nicht da
Nicht jeder Lese-Hook schützt sich gegen eine leere Id — und die entsteht
schnell, weil useParams() mit Default (const { fileId = '' } = useParams())
einen Leerstring liefert, solange der Parameter fehlt:
| Hook | Verhalten bei leerer Id |
|---|---|
useFile(id) | kein Schutz — die Query läuft und der Request geht raus |
usePerson(id) | enabled: Boolean(id) — bleibt aus |
useDocumentsByFile(id, { enabled }) | enabled bestimmst du; der FileManager setzt selbst enabled: !!fileId |
Bei useFile guckst du also selbst hin. Am ehrlichsten ist es, gar nicht zu
rendern, solange die Id fehlt:
const { fileId } = useParams();
if (!fileId) return <Navigate to=".." replace />; // vor allen Widgets
const useAkte = () => useFile(fileId);
Ein if zwischen Hook-Aufrufen ist hier unbedenklich, weil es die Komponente
verlässt, bevor Hooks laufen — nicht zu verwechseln mit einem bedingten
Hook-Aufruf (siehe unten).
Die Quelle darf nicht wechseln
dataSource() läuft in jedem Render — und zwar unbedingt. Damit gelten dort die
Hook-Regeln, obwohl es „nur" eine Prop ist:
// ❌ mal der eine, mal der andere Hook → Reihenfolge der Hooks ändert sich
const useQuelle = () => (fileId ? useFile(fileId) : useFiles());
// ❌ bedingter Aufruf
const useQuelle = () => { if (!fileId) return undefined; return useFile(fileId); };
// ✅ ein Hook, immer derselbe — die Bedingung steckt in `enabled` bzw. weiter oben
const useQuelle = () => useFile(fileId);
Dasselbe gilt für die Widgets selbst: Sie wählen ein Store-Bündel (store ?? filesStore) vor dem Aufruf aus und rufen die Hooks danach unbedingt auf. Ein
store, den du zur Laufzeit austauschst, verletzt diese Annahme.
Objekt oder Liste — je Widget verschieden
Field, Badge und FileManager erwarten ein Objekt
(UseQueryResult<JsonObject>), List ein Array (UseQueryResult<any[]>).
Eine Listenquelle passt deshalb nicht in ein Objekt-Widget — TypeScript lehnt
dataSource={useFiles} an einem Field ab:
Type 'JsonObject[]' is not assignable to type 'JsonObject'.
Willst du einen einzelnen Eintrag aus einer Liste zeigen, hol ihn heraus und
wickle ihn in staticSource:
const { data } = useFiles();
<Field dataSource={staticSource(data?.[0])} jsonPath="title.de" /> // erste Akte
<List dataSource={useFiles} columns={columns} /> // die ganze Liste
Solange data noch undefined ist, bedeutet staticSource(undefined)
„lädt noch" — das Field zeigt also von sich aus ein Skeleton.
Der FileManager ist ein Sonderfall: Er liest aus der Quelle nur den Pfad
$apiId und lädt damit die Dokumente. Zeigt deine Quelle auf ein Objekt ohne
$apiId, bleibt die Dokumentliste leer, ohne dass ein Request scheitert.
Ohne Tenant geht nichts
Alle mandantenbezogenen Hooks lösen den Tenant über den Context auf. Fehlt er —
weil der Aufruf außerhalb einer App und damit außerhalb des TenantProvider
passiert —, wirft der Hook, statt gegen einen kaputten Pfad zu laufen:
Error: Kein Tenant verfügbar: weder als Parameter übergeben noch aus dem Context.
Innerhalb einer App ist das erledigt; das Sachbearbeiter-Cockpit mountet jede App in ihrem
Provider. Für Storybook, Tests oder Vorschauen gibt es zwei Wege: den Tenant
explizit übergeben (() => useFile(id, 'MeinMandant')) oder ganz ohne Hook
arbeiten und staticSource(obj) verwenden. Mehr dazu unter
Mandanten.
Manche Hooks laufen erst auf Kommando
usePersons ist standardmäßig aus und lädt nur mit enabled: true — als
dataSource also:
const usePersonenListe = () => usePersons({ enabled: true });
Ohne das bleibt die Liste leer, auch im Mock-Modus, und es gibt keinen Fehler zu sehen.
Wer Fehler anzeigt — und wer nicht
Lade- und Fehlerzustände stecken im Query-Ergebnis; was daraus wird, entscheidet das Widget:
| Widget | Während des Ladens | Wenn die Quelle scheitert |
|---|---|---|
Field | Skeleton | Platzhalter — — keine Fehlermeldung |
List | 5 Skeleton-Zeilen | „Keine Einträge gefunden." wie bei leerem Ergebnis |
Badge | leerer Platzhalter | leerer Platzhalter |
Submitter | Skeleton im Kopf | Kopf bleibt dauerhaft im Skeleton, Zusatzfelder zeigen — |
FileManager | Skeleton | Entwickler-Hinweis zum fehlenden $apiId |
Beim FileManager lohnt die Unterscheidung: Die rote Meldung „Dokumente konnten
nicht geladen werden." samt „Erneut versuchen" gilt der Dokumenten-Query.
Scheitert dagegen die dataSource, findet das Widget kein $apiId und zeigt
denselben Entwickler-Hinweis wie bei einem falschen Pfad.
Ein fehlgeschlagener Request sieht also fast überall aus wie „keine Daten" — nirgends steht die eigentliche Fehlermeldung. Wenn dir das zu still ist, prüfe den Zustand selbst; du hast dieselbe Query in der Hand:
const { isError, error } = useFile(fileId);
if (isError) return <p role="alert">Akte nicht ladbar: {error.message}</p>;
Das kostet keinen zweiten Request: es ist derselbe Query-Key wie in useAkte.
