Startseite / Artikel / Eine vorhersehbare React-Baseline mit TypeScript, Zustand und typisierten Services

Eine vorhersehbare React-Baseline mit TypeScript, Zustand und typisierten Services

Ein kleines Skeleton für React, TypeScript und Zustand, das Stores, typisierte API-Dienste sowie Komponenten voneinander trennt, zusammen mit Konventionen für die Struktur, asynchrone Zustände und Tests.

1207 Wörter

Der erste Bildschirm eines neuen Projekts ist winzig, doch die dahinterliegenden Entscheidungen – wo der Zustand gespeichert wird, wie Daten abgerufen werden und wie streng die Typisierung erfolgt – prägen alles, was danach kommt. Eine kleine „Hello App“ eignet sich hervorragend, um diese Entscheidungen zu treffen. In dieser Anleitung wird eine solche App mit React, TypeScript und Zustand erstellt, erklärt wird, wofür jeder Bestandteil des Skeletts zuständig ist, und die zugrunde liegenden Konventionen werden in Regeln umgewandelt, die Sie im Laufe des Wachstums der Codebasis anwenden können.

Warum React, TypeScript und Zustand zusammen

Jedes Werkzeug befasst sich mit einem anderen Aspekt:

  • React bietet eine deklarative Benutzeroberfläche, ein ausgereiftes Ökosystem sowie leistungsstarke Werkzeuge, und durch Komposition bleiben die Komponenten lesbar.
  • TypeScript erkennt Fehler bereits zur Kompilierzeit, macht Refactoring sicherer und verwandelt die Eigenschaften von Komponenten sowie die Struktur des Zustands in selbst dokumentierende Verträge.
  • Zustand ist eine kleine State-Bibliothek ohne viele Formalitäten: Der Store besteht aus einem Haken, das Datenmodell setzt sich aus einfachen Objekten und Funktionen zusammen, und die Komponenten hören nur auf die Teile, die sie lesen.
  • Fangen Sie mit diesen drei Elementen plus einem Router an und fügen Sie eine Bibliothek nur dann hinzu, wenn ein konkreter Bedarf entsteht.

    Das Skelett: Store, Service und Komponente

    Das untenstehende Beispiel ist als eine einzige Aufzählung dargestellt, repräsentiert aber drei Dateien, von denen jede eine einzige Aufgabe hat. store/counterStore.ts definiert einen typisierten Zustand-Store, der einen count, ein loading-Flag, eine synchrone increment-Aktion und eine asynchrone loadInitial-Aktion enthält. services/counterApi.ts umhüllt den HTTP-Aufruf in eine typisierte Funktion, die bei einer nicht-OK-Antwort einen Fehler auslöst. App.tsx liest die einzelnen Werte über Selektoren und triggert in einem Effect den initialen Ladevorgang.

    Achten Sie auf drei Dinge. Der Store ruft niemals direkt fetch auf; er delegiert diese Aufgabe an den Service, sodass die Netzwerkschicht unabhängig ausgetauscht oder emuliert werden kann. Der finally-Block stellt sicher, dass loading auch im Falle eines Anfragerfolgs zurückgesetzt wird. Zudem abonniert die Komponente jedes Feld mit seinem eigenen Selektor, wodurch sie nur dann neu gerendert wird, wenn sich ein Wert ändert, den sie tatsächlich verwendet.

    // store/counterStore.ts
    import { create } from "zustand"
    
    type CounterState = {
      count: number
      loading: boolean
      increment: () => void
      loadInitial: () => Promise<void>
    }
    
    export const useCounter = create<CounterState>((set, get) => ({
      count: 0,
      loading: false,
      increment: () => set({ count: get().count + 1 }),
      loadInitial: async () => {
        set({ loading: true })
        try {
          const value = await fetchInitialCount()
          set({ count: value })
        } finally {
          set({ loading: false })
        }
      },
    }))
    
    // services/counterApi.ts
    export type CounterResponse = { value: number }
    
    export async function fetchInitialCount(): Promise<number> {
      const res = await fetch("/api/counter")
      if (!res.ok) throw new Error("Failed to load")
      const data = (await res.json()) as CounterResponse
      return data.value
    }
    
    // App.tsx
    import React, { useEffect } from "react"
    import { useCounter } from "./store/counterStore"
    
    export default function App() {
      const count = useCounter(s => s.count)
      const loading = useCounter(s => s.loading)
      const increment = useCounter(s => s.increment)
      const loadInitial = useCounter(s => s.loadInitial)
    
      useEffect(() => {
        void loadInitial()
      }, [loadInitial])
    
      return (
        <main>
          <h1>Hello App</h1>
          <p>{loading ? "Loading..." : `Count: ${count}`}</p>
          <button onClick={increment} disabled={loading}>
            Increment
          </button>
        </main>
      )
    }
    

    Es gibt einige Details, die vor dem Einfügen in ein echtes Projekt überprüft werden sollten. Als separate Dateien benötigt der Store eine explizite Importierung von fetchInitialCount aus dem Service-Modul. increment liest den aktuellen Wert mit get() ein; die funktionale Form set((s) => ({ count: s.count + 1 })) drückt denselben Zweck aus und ist der gängigere Ansatz. Schließlich beendet ein fehlgeschlagener Anfragenversuch derzeit lediglich das Laden und wirft erneut einen Fehler aus, wodurch ein unverarbeiteter Ablehnungsfall entsteht, da der Effekt die Promise mit void ignoriert; durch Hinzufügen eines error-Feldes im Store und das Erfassen des Fehlers dort erhält die Benutzeroberfläche etwas, was sie anzeigen kann.

    Eine Struktur, die auch bei Wachstum klar bleibt

    Gruppieren Sie den Code nach Funktionen und nicht nach Dateitypen. Ein Ordner pro Funktion, der deren Store, Typen sowie die Benutzeroberfläche enthält, ist leichter zu navigieren als die obersten Verzeichnisse components, utils und services, die bei jeder Änderung berührt werden müssen. Gemeinsam genutzte Hilfsfunktionen sowie das Design-System erhalten eigene Module. Für einen detaillierteren Vergleich von Layouts siehe die Auswahl einer React-Ordnerstruktur.

    TypeScript als Vertragsschicht

    Betrachten Sie Typen als Teil der öffentlichen API jedes Moduls. Exportieren Sie die von den Nutzern benötigten Typen und halten Sie die internen Typen privat. Schalten Sie von Anfang an strict (was auch noImplicitAny beinhaltet) sowie strenge JSX-Einstellungen ein; das Hinzufügen von Strenge später ist weitaus schwieriger. Verwenden Sie Hilfstypen wie Pick, Omit und ReturnType, damit abgeleitete Type im Einklang bleiben, und typisieren Sie Ihre Selektoren.

    Zustand ohne Hindernisse verwenden

    Trennen Sie den Zustand in kleine Stores nach Domäne auf, beispielsweise authStore und todosStore, wobei jeder mit create erstellt wird. Halten Sie die Selektoren eng gefasst: Das Auswählen eines einzelnen Feldes vermeidet erneute Darstellungen, wenn unverwandte Felder sich ändern, während das Auswählen eines neu erstellten Objekts bei jedem Aufruf zusätzliche Darstellungen verursachen kann, es sei denn, Sie verwenden einen Hilfsfunktion für oberflächliche Gleichheit. Zustand legt keine Reduzierer fest, sodass Updates kurz und vorhersehbar bleiben, solange Sie neue Werte erzeugen anstatt den Zustand zu verändern.

    Komponenten- und Formmuster

    Trennen Sie Container von Präsentationskomponenten. Container kommunizieren mit Stores und enthalten die Logik; Präsentationskomponenten erhalten typisierte Props, bleiben rein und sind einfach zu testen. Für einfache Formulare reichen kontrollierte Eingabefelder aus; für komplexe Fälle sorgt eine leichte Bibliothek wie react-hook-form in Kombination mit einem TypeScript-kompatiblen Schema dafür, dass Validierung und Typen übereinstimmen. Verwenden Sie React.memo, useMemo und useCallback nur dann, wenn Profilanalysen einen Vorteil zeigen.

    Asynchrone Aufgaben und Nebeneffekte

    Platzieren Sie jeden API-Aufruf in einem typisierten Service und lassen Sie die Stores die Services aufrufen, wobei sie nur den Zustand speichern, den die Benutzeroberfläche benötigt. Verfolgen Sie im Store den Status der Anfragen sowie deren Lade-, Fehler- und Erfolgszustände, damit die Benutzeroberfläche stets das widergespiegelt, was tatsächlich vor sich geht. Stellen Sie veraltete, lange laufende Anfragen mit AbortController ein und speichern Sie im Store eine Anfrage-ID oder einen Frischeindikator, damit eine ältere Antwort keine neuere überschreiben kann.

    Testing und Codequalität

    Unit-Tests für die Kerngeschäftslogik sowie Store-Selektoren sind kostengünstig und bringen schnell Vorteile. Kombinieren Sie ESLint mit TypeScript-kompatiblen Regeln sowie Prettier und führen Sie sie automatisch in einem pre-commit-Hook aus. Erstellen Sie Test- und Storybook-Daten mithilfe typisierter Fixture-Fabriken, sodass Fehler bereits zur Kompilierzeit auftreten, wenn sich ein Modell ändert.

    Wachstum ohne Neuschreiben

    Fügen Sie Funktionen vertikal hinzu: Eine neue Eigenschaft bedeutet einen neuen Ordner, eine Speicher- und Routing-Lösung, während Lokalisierung und Theming in eigenen Modulen untergebracht sind. Wenn sich eine API oder ein Datenmodell ändert, soll der Compiler auf alle gebrochenen Verträge hinweisen. Führen Sie Caching, Normalisierung und optimistische Aktualisierungen nur ein, wenn tatsächliche Anforderungen dies erfordern; wenn der Serverzustand die Speicherlösung zunehmend dominiert, ist oft eine spezielle Datenabrufbibliothek ein besserer Ort dafür als Zustand.

    Kernpunkte

    • Halten Sie Speicherlösungen, Dienste und Komponenten in separaten, einzigartigen Modulen – auch in den kleinsten Anwendungen.
    • Lesen Sie den Zustand über enge Selektoren sowie das explizite Laden des Modells und den Fehlerzustand ein.
    • Führen Sie strikten TypeScript frühzeitig ein und lassen Sie die Typen die Modulgrenzen dokumentieren und durchsetzen.
    • Organisieren Sie nach Funktionen, fügen Sie Abhängigkeiten nur auf Anfrage hinzu und optimieren Sie nach Messungen.
  • Schützen Sie asynchrone Abläufe durch Stornierungs- und Frischeprüfungen, bevor Rennbedingungen die Benutzer erreichen.