02Frontend5 min

Wann sich ein globaler Store seinen Platz verdient

Globaler State rechtfertigt sich nicht durch Bequemlichkeit. Er rechtfertigt sich, wenn eine Zwischenkomponente Props bekommt, die sie nicht benutzt, nur um sie nach unten weiterzureichen.

Von Salva Castells

Titelbild: Fabian Horst, CC BY-SA 4.0.
In diesem Beitrag

In einem SaaS zur Bildgenerierung hatte ich eine Seite mit zwei Kindern: eine Seitenleiste mit den Bedienelementen und einen Galerie-Dialog. Die Seite zeichnete nichts Eigenes. Sie war das hier:

export default function BoardPage() {
  const [attachedImages, setAttachedImages] = useState<AttachedImage[]>([]);
  const [galleryOpen, setGalleryOpen] = useState(false);
  const [galleryFilters, setGalleryFilters] = useState<GalleryFilters>({
    type: "all",
    models: [],
  });

  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar attachedImages={attachedImages} ... />

Nichts von diesem State wurde von der Seite benutzt. Sie deklarierte ihn, weil sie der gemeinsame Vorfahr der beiden war, die ihn tatsächlich benutzten. Das ist Prop-Drilling in seiner ehrlichsten Form: Der State steigt so weit hoch, bis er am wenigsten stört, und läuft über eine Prop-Leitung wieder hinunter.

Das konkrete Signal, das mich migrieren lässt

Es ist nicht "es gibt viele Props". Es ist spezifischer: Eine Komponente bekommt eine Prop, liest sie nicht und reicht sie unverändert an die nächste weiter.

In diesem Board bekam die Seitenleiste selectedModels und onModelsChange nur, um sie an das Modell-Popover weiterzugeben. Sie schaute sie nie an. Die Seite bekam galleryFilters aus ihrem eigenen useState, nur um sie dem Dialog zu geben. Jedes Mal, wenn ich ein neues Generierungsfeld hinzufügte, fasste ich drei Dateien an, und keine der drei hatte Logik, die mit diesem Feld zu tun hatte.

Das zweite Signal, abgeleitet aus dem ersten: Die Prop-Typen werden zum Spiegelbild des States. GalleryDialogProps hatte vier Einträge — galleryOpen, galleryFilters, onGalleryClose, onGalleryFiltersChange — und keiner beschrieb, was der Dialog tut. Sie beschrieben, wie man es ihm mitteilt.

Die Migration waren vier Commits hintereinander an einem Nachmittag. Zuerst ein use-board-store.ts mit der Galerie und den angehängten Bildern, und die Seite als Konsument. Danach lasen die Kinder direkt aus dem Store, und da sah die Seite so aus:

export default function BoardPage() {
  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar />
      <GalleryDialog onImageClick={...} />

Null State-Props. Die Komponente war wieder das, was sie behauptete zu sein: ein Layout. Als ich im dritten Commit prompt, selectedAspectRatio, selectedModels und imageCount in den Store zog, verlor die Seitenleiste 83 Zeilen und gewann 9.

Was ich nicht in den Store stecke

Die Versuchung nach so einer Migration ist, alles hineinzustecken. Das sollte man nicht, und der Diff selbst zeigt warum.

Das Modell-Popover hatte vier Props: open, onOpenChange, selectedModels, onModelsChange. Nur zwei wanderten in den Store. Die anderen zwei blieben, sind aber nach unten gerutscht:

export function ModelsPopover() {
  const { selectedModels, setSelectedModels } = useBoardStore();
  const [open, setOpen] = useState(false);

Das Kriterium ist, wer diesen Wert alles lesen muss. selectedModels liest das Popover, liest die Seitenleiste für die Kostenschätzung und liest der Seitenverhältnis-Selektor, um zu wissen, welche Proportionen verfügbar sind. open liest niemand außer dem Popover selbst. Hätte ich es in den Store gehoben, hätte ich fünf xOpen-Booleans ohne jeden Grund im globalen State, und den Bug an dem Tag, an dem zwei davon gleichzeitig offen bleiben.

Ich stecke auch nichts Ableitbares in den Store. Die Berechnung der verfügbaren Proportionen ist die Schnittmenge dessen, was jedes ausgewählte Modell unterstützt. Das ist eine Funktion von selectedModels, also lebt sie dort, wo sie benutzt wird, und wird neu berechnet. Ein Feld availableAspectRatios im Store wäre eine Kopie, die man synchron halten muss, und an dem Tag, an dem sie auseinanderläuft, sieht der Nutzer ein Seitenverhältnis, das das Modell nicht akzeptiert.

  • In den Store: was zwei verschiedene Äste des Baums lesen.
  • Lokal in der Komponente: das Flüchtige einer Interaktion — offen/geschlossen, Hover, Fokus.
  • Nirgendwohin: was sich aus dem Obigen berechnen lässt.

Der Canvas-Editor, wo der Baum flach ist und es trotzdem keinen Store gibt

Um dieselbe Zeit arbeitete ich an einem Editor für Büro-Grundrisse, Figma-artig, aber für Schreibtische, auf Basis von Konva. Da sagt die Intuition "das schreit nach einem Store": Es gibt eine Leinwand, eine Toolbar, ein Eigenschaftspanel, und alle fassen dieselben Formen an.

Er hat keinen Store. Es ist weiterhin useState in der Wurzelkomponente:

const [floorPlan, setFloorPlan] = useState<FloorPlan>(initialPlan || DEFAULT_FLOOR_PLAN);
const [editorState, setEditorState] = useState<EditorState>({
  selectedShapeId: null, tool: "select", zoom: 1, isDragging: false,
});

Und es funktioniert, weil der Baum flach ist. Der Editor hat drei direkte Kinder, und jedes bekommt, was es wirklich benutzt. Niemand reicht etwas weiter. Er erfüllt das Kriterium von oben auf der guten Seite: Es gibt keine Zwischenkomponente, die Props bekommt, die sie nicht liest.

Was in diesem Editor wehtat, war nicht der State, sondern die Typenexplosion. Es gab neun Schreibtischvarianten als eigene Typen: dual-desk-facing, quad-desk-parallel, hex-desk-facing, octo-desk-parallel. Neun bis auf den Namen identische Interfaces, neun Zweige im switch der Leinwand, neun Komponentendateien. Der Refactor, der das behob, klappte sie auf zwei zusammen, multi-desk-facing und multi-desk-parallel, mit einem Feld seats:

{Array.from({ length: seats }, (_, index) => (
  <SingleDesk key={index} ... />
))}

Das löschte sieben Dateien und entfernte 453 Zeilen aus der Komponente, die den Plan in der Buchungsansicht zeichnet. Kein Store hätte das behoben, denn das Problem war nie, wo der State wohnte, sondern wie viele Formen er hatte.

Was sich ändert, wenn der State die Props verlässt

Man verliert etwas Echtes: An der Signatur einer Komponente erkennst du nicht mehr, wovon sie abhängt. Du musst sie öffnen. Tests können State nicht mehr über Props injizieren und müssen den Store vorbereiten. Und die Reibung verschwindet, die dich warnte, dass du zu viel koppelst — die Prop-Leitung war hässlich, aber sie war ein ehrlicher Zähler für Kopplung.

Deshalb verdient sich der Store seinen Platz, er bekommt ihn nicht geschenkt. Die Frage ist nicht, wie viele Props es gibt. Die Frage ist, ob irgendeine Komponente auf dem Weg eine bekommt, die sie nicht benutzt.