Accueil / Articles / Une base React prévisible avec TypeScript, Zustand et des services typés

Une base React prévisible avec TypeScript, Zustand et des services typés

Un squelette minimaliste en React, TypeScript et Zustand qui sépare les stores, les services API typés des composants, ainsi que des conventions pour la structure, l’état asynchrone et les tests.

1207 mots

L’écran initial d’un nouveau projet est minimal, mais les décisions qui le sous-tendent — l’endroit où se trouve l’état, la manière dont les données sont récupérées et le degré de strictité dans le typage — influencent tout ce qui suit. Une petite « Hello App » est l’endroit idéal pour les définir. Cette présentation permet de créer une telle application avec React, TypeScript et Zustand, d’expliquer la fonction de chaque élément constitutif du squelette, et de transformer ces conventions en règles applicables à mesure que la base de code s’agrandit.

Pourquoi React, TypeScript et Zustand ensemble ?

Chacun de ces outils couvre un aspect différent :

  • React offre une interface utilisateur déclarative, un écosystème mature et des outils puissants ; la composition permet de garder les composants lisibles.
  • TypeScript permet de détecter les erreurs en temps de compilation, rend le refactoring plus sûr et transforme les propriétés des composants ainsi que la structure des stores en contrats auto-documentés.
  • Zustand est une petite bibliothèque de type état qui ne comporte que très peu de formalités : le store est un crochet, le modèle de données se compose d’objets et de fonctions simples, et les composants n’écoutent que les données qu’ils lisent.
  • Commencez avec ces trois éléments ainsi qu’un routeur, et n’ajoutez une bibliothèque que lorsque un besoin concret se fait sentir.

    L’ossature : store, service et composant

    L’exemple ci-dessous est présenté sous forme d’une seule liste, mais il correspond en réalité à trois fichiers, chacun ayant une tâche unique. store/counterStore.ts définit un store Zustand typé qui contient une variable count, un indicateur loading, une action synchrone increment et une action asynchrone loadInitial. services/counterApi.ts encapsule l’appel HTTP derrière une fonction typée qui lance une exception en cas de réponse non OK. App.tsx lit les valeurs individuelles à l’aide de sélecteurs et déclenche le chargement initial dans un effet.

    Remarquez trois choses. Le magasin n’appelle jamais directement fetch ; il délègue cette tâche au service, ce qui permet de remplacer ou de simuler la couche réseau de manière indépendante. Le bloc finally garantit que loading est réinitialisé même en cas d’échec de la requête. De plus, le composant s’abonne à chaque champ avec son propre sélecteur, de sorte qu’il se rérendera uniquement lorsque la valeur qu’il utilise réellement change.

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

    Quelques détails méritent d’être affinés avant de copier ce code dans un projet réel. En tant que fichiers séparés, le stockage nécessite une importation explicite de fetchInitialCount depuis le module de service. La fonction increment lit la valeur actuelle à l’aide de get() ; la forme fonctionnelle set((s) => ({ count: s.count + 1 })) exprime la même intention et constitue l’idiome le plus courant. Enfin, une requête échouée ne fait actuellement que terminer le chargement et rejeter l’erreur, ce qui entraîne un rejet non géré car l’effet supprime la promesse avec void ; en ajoutant un champ error au stockage et en capturant l’erreur à cet endroit, l’interface utilisateur dispose d’informations fiables à afficher.

    Structure qui reste claire au fur et à mesure de son développement

    Classifiez les codes par fonctionnalité plutôt que par type de fichier. Un dossier par fonctionnalité contenant son stockage, ses types et son interface utilisateur est plus facile à naviguer que les répertoires de niveau supérieur components, utils et services que toute modification doit impérativement toucher. Les outils partagés et le système de conception disposent de leurs propres modules. Pour une comparaison plus approfondie des layouts, consultez choisir une structure de dossier React.

    TypeScript en tant que couche de contrat

    Tenez les types pour faisant partie de l’API publique de chaque module. Exportez les types nécessaires aux utilisateurs et gardez les types internes privés. Activez strict (qui inclut noImplicitAny) ainsi que les paramètres stricts de JSX dès le début ; introduire la stricteur ultérieurement est bien plus difficile. Utilisez des types utilitaires tels que Pick, Omit et ReturnType afin que les types dérivés restent en synchronisation, et spécifiez les types de vos sélecteurs.

    Utiliser Zustand sans friction

    Divisez l’état en petits stores par domaine, par exemple authStore et todosStore, chacun créé à l’aide de create. Restez précis dans les sélecteurs : en sélectionnant un seul champ, on évite de re-render lorsque des champs non liés changent, tandis que la sélection d’un objet nouvellement créé à chaque appel peut entraîner des re-render supplémentaires, sauf si vous utilisez un outil d’égalité superficielle. Zustand ne impose pas de reducers, donc les mises à jour restent courtes et prévisibles tant que vous générez de nouvelles valeurs plutôt que de muter l’état.

    Modèles de composants et de formulaires

    Séparez les conteneurs des composants de présentation. Les conteneurs communiquent avec les stores et gèrent la logique ; les éléments de présentation reçoivent des props prédéfinis, restent purs et sont faciles à tester. Pour les formulaires simples, des champs contrôlés suffisent ; pour ceux qui sont plus complexes, une bibliothèque légère comme react-hook-form associée à un schéma compatible TypeScript permet de maintenir la validation et les types en cohérence. Utilisez React.memo, useMemo et useCallback uniquement lorsque l’analyse des performances en montre l’utilité.

    Travaux asynchrones et effets secondaires

    Placer chaque appel API dans un service typé, et permettre aux stores d’appeler ces services en ne conservant que l’état nécessaire à l’interface utilisateur. Suivre l’état des requêtes, leur chargement, les erreurs et les succès dans le store afin que l’interface reflète toujours ce qui se passe réellement. Annuler les requêtes anciennes avec AbortController, et conserver un identifiant de requête ou un marqueur de fraîcheur dans le store pour éviter qu’une réponse plus ancienne ne supprime une réponse plus récente.

    Tests et qualité du code

    Les tests unitaires pour la logique métier principale et les sélecteurs de store sont peu coûteux et donnent rapidement des résultats. Combinez ESLint avec des règles compatibles TypeScript ainsi que Prettier, et exécutez-les automatiquement dans un hook pre-commit. Créez des données de tests et de Storybook à l’aide de fabricants de fichiers typés afin que des erreurs soient détectées au moment de la compilation en cas de modification d’un modèle.

    Évoluer sans réécriture

    Ajoutez des fonctionnalités de manière verticale : une nouvelle fonctionnalité correspond à un nouveau dossier, avec des mécanismes de stockage et d’acheminement, tandis que la localisation et les thèmes sont gérés dans leurs propres modules. Lorsqu’une API ou un modèle de données change, laissez le compilateur indiquer tous les contrats non respectés. Introduisez le cache, la normalisation et les mises à jour optimistes uniquement lorsque des besoins réels l’exigent ; si l’état du serveur commence à dominer le stockage, une bibliothèque dédiée au chargement des données est souvent un meilleur choix que Zustand.

    Points clés

    • Gardez les stores, les services et les composants dans des modules séparés à vocation unique, même dans les applications les plus petites.
    • Lisez l’état à l’aide de sélecteurs ciblés, et chargez explicitement le modèle ainsi que les états d’erreur.
    • Activez TypeScript de manière stricte dès le début, afin que les types documentent et respectent les limites entre modules.
    • Organisez le code par fonctionnalité, n’ajoutez des dépendances qu’en cas de besoin, et optimisez après avoir effectué des mesures.
  • Protégez les flux asynchrones grâce à des vérifications de suppression et de fraîcheur avant que les conditions de concurrence n’affectent les utilisateurs.