Strona główna / Artykuły / Przewidywalna baza dla React z użyciem TypeScript, Zustand i typowanych usług

Przewidywalna baza dla React z użyciem TypeScript, Zustand i typowanych usług

Mały szkielet w React, TypeScript i Zustand, który oddziela magazyny danych, typowane usługi API od komponentów, a także zawiera konwencje dotyczące struktury, stanu asynchronicznego i testowania.

1207 słów

Pierwszy ekran nowego projektu jest mały, ale decyzje stojące za nim – gdzie przechowywany jest stan, w jaki sposób pobierane są dane oraz jak ściśle określone są typy – kształtują wszystko, co następuje później. Mała aplikacja „Hello App” jest idealnym miejscem, aby je ustalić. Ten przewodnik pokazuje, jak ją stworzyć za pomocą React, TypeScript i Zustand, wyjaśnia, za co odpowiada każda część tego szkieletu, oraz przekształca podstawowe zasady w reguły, których można stosować w miarę rozwoju bazy kodu.

Dlaczego React, TypeScript i Zustand razem

Każde z tych narzędzi obejmuje inny aspekt:

  • React zapewnia deklaratywną interfejs użytkownika, dojrzały ekosystem oraz potężne narzędzia, a zasada kompozycji sprawia, że komponenty są łatwe do odczytania.
  • TypeScript wykrywa błędy w czasie kompilacji, ułatwia refaktoryzację i przekształca właściwości komponentów oraz strukturę pamięci w samodokumentujące się umowy.
  • Zustand to mała biblioteka stanowa bez zbędnych formalności: magazynem jest haczek, modelem danych są zwykłe obiekty i funkcje, a komponenty słuchają jedynie tych danych, które odczytują.
  • Zacznij od tych trzech elementów plus routera, a bibliotekę dodawaj dopiero wtedy, gdy pojawi się konkretna potrzeba.

    Skelet: magazyn, usługa i komponent

    Poniższy przykład przedstawiono jako jedną listę, ale w rzeczywistości obejmuje trzy pliki, z których każdy ma jedno zadanie. store/counterStore.ts definiuje typizowany magazyn Zustand zawierający count, flagę loading, synchroniczną akcję increment oraz asynchroniczną akcję loadInitial. services/counterApi.ts otacza wywołanie HTTP funkcją typizowaną, która rzuca błąd w przypadku odpowiedzi innej niż OK. App.tsx odczytuje poszczególne wartości za pomocą selektorów i uruchamia początkowe ładowanie w ramach efektu.

    Zwróć uwagę na trzy rzeczy. Sklep nigdy nie wywołuje bezpośrednio funkcji fetch; deleguje to na usługę, dzięki czemu warstwa sieciowa może być zamieniona lub symulowana niezależnie. Blok finally gwarantuje, że stan loading zostanie sformatowany na nowo nawet w przypadku nieudanej prośby. Ponadto komponent subskrybuje się do każdego pola za pomocą własnego selektora, więc ponownie renderuje się tylko wtedy, gdy zmienia się wartość, której faktycznie używa.

    // 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>
      )
    }
    

    Niektóre szczegóły wymagają dopracowania przed wdrożeniem tego rozwiązania w rzeczywistym projekcie. Jako oddzielne pliki, magazyn musi zawierać wyraźny import funkcji fetchInitialCount z modułu usług. Funkcja increment odczytuje aktualną wartość za pomocą get(); forma funkcyjna set((s) => ({ count: s.count + 1 })) realizuje tę samą funkcję i jest bardziej powszechnym rozwiązaniem. Wreszcie, nieudana prośba obecnie jedynie zatrzymuje proces ładowania i ponownie rzuca błąd, co powoduje nierozwiązany problem, ponieważ efekt usuwa obietnicę za pomocą void; dodanie pola error do magazynu oraz przechwytywanie błędu tam umożliwia interfejsowi wyświetlenie dokładnych informacji.

    Struktura, która pozostaje przejrzysta w miarę rozwoju

    Kod grupuj według funkcjonalności, a nie według typu pliku. Katalog dla każdej funkcjonalności, zawierający jej składniki, typy oraz interfejs użytkownika, jest łatwiejszy do nawigacji niż katalogi najwyższego poziomu components, utils i services, które muszą być modyfikowane przy każdej zmianie. Wspólne narzędzia i system projektowy mają swoje własne moduły. Aby uzyskać bardziej szczegółowe porównanie układów, zobacz wybór struktury katalogów w React.

    TypeScript jako warstwa umowy

    Traktuj typy jako część publicznego API każdego modułu. Eksportuj te typy, których potrzebują użytkownicy, a te wewnętrzne zachowaj jako prywatne. Od samego początku włącz ustawienie strict (które obejmuje również noImplicitAny) oraz ścisłe ustawienia JSX; wprowadzanie rygorów później jest znacznie trudniejsze. Korzystaj z typów pomocniczych takich jak Pick, Omit i ReturnType, aby typy pochodne pozostawały zsynchronizowane, oraz określaj typy swoich selektorów.

    Korzystanie z Zustand bez przeszkód

    Rozdziel stan na małe zbiory według domeny, na przykład authStore i todosStore, z których każdy jest tworzony za pomocą create. Używaj wąskich selektorów: wybór pojedynczego pola zapobiega ponownemu renderowaniu, gdy zmieniają się niepowiązane pola, natomiast wybór świeżo utworzonego obiektu przy każdej wywołaniu może powodować dodatkowe renderowania, chyba że użyjesz narzędzia do sprawdzania równości powierzchownej. Zustand nie narzuca reduktorów, więc aktualizacje pozostają krótkie i przewidywalne, o ile tworzysz nowe wartości zamiast modyfikować stan.

    Wzory komponentów i form

    Rozdzielaj kontenery od komponentów prezentacyjnych. Kontenery komunikują się z magazynami danych i zawierają logikę; elementy prezentacyjne otrzymują dane typowane, pozostają czyste i są łatwe do testowania. W przypadku prostych form wystarczają kontrolowane pola wprowadzania danych; w przypadku złożonych biblioteka lekka, taka jak react-hook-form w połączeniu ze schematem rozumiejącym TypeScript, pomaga utrzymać spójność walidacji i typów. Używaj React.memo, useMemo oraz useCallback tylko wtedy, gdy analiza wydajności pokazuje korzyści z ich zastosowania.

    Praca asynchroniczna i efekty uboczne

    Zawartość każdej wywołania API w usłudze typowanej, a sklepy powinny wywoływać te usługi, przechowując jedynie stan niezbędny interfejsowi użytkownika. Śledź w sklepie status zapytań, proces ładowania, błędy oraz sukcesy, aby interfejs zawsze odzwierciedlał to, co faktycznie się dzieje. Anuluj stare, długotrwałe zapytania za pomocą AbortController, a także przechowuj w sklepie identyfikator zapytania lub marker aktualności, aby starsza odpowiedź nie mogła przepisać nowszej.

    Testowanie i jakość kodu

    Testy jednostkowe dla kluczowej logiki biznesowej oraz selektorów sklepu są tanie i szybko przynoszą korzyści. Połącz ESLint z regułami dostosowanymi do TypeScript oraz Prettier i uruchamiaj je automatycznie w hooku pre-commit. Twórz dane do testów i Storybook za pomocą fabryk przykładowych danych typowanych, aby doszło do błędu już na etapie kompilacji w przypadku zmiany modelu.

    Rozwój bez przepisywania kodu

    Dodawaj funkcjonalności w sposób pionowy: nowa cecha oznacza nową folder, magazyn i ścieżkę przekierowania, podczas gdy lokalizacja i tematyka znajdują się w osobnych modułach. Gdy zmienia się API lub model danych, niech kompilator wskazuje na każdy złamany kontrakt. Wprowadzaj cacheowanie, normalizację i optymistyczne aktualizacje tylko wtedy, gdy wymagają tego rzeczywiste potrzeby; jeśli stan serwera zaczyna dominować w magazynie, dedykowana biblioteka do pobierania danych jest często lepszym rozwiązaniem niż Zustand.

    Główne wnioski

    • Zachowuj magazyny, usługi i komponenty w oddzielnych, jednocechowych modułach, nawet w najmniejszej aplikacji.
    • Czytaj stan za pomocą wąskich selektorów oraz wyraźnie obsługuj ładowanie modeli i stan błędów.
    • Włącz surowy TypeScript od samego początku i niech typy dokumentują oraz egzekwują granice modułów.
    • Organizuj kod według funkcjonalności, dodawaj zależności tylko w razie potrzeby i optymalizuj po pomiarach.
  • Ochrona asynchronicznych przepływów poprzez sprawdzanie możliwości anulowania i aktualności, zanim warunki konkurencji dotrą do użytkowników.
  • Literatura pokrewna