Accueil / Articles

Diriger les agents de codage IA à l’aide de quatre modèles d’architecture React nommés

Apprenez quatre patterns React (composition de sections, hooks personnalisés, composants polymorphes, réducteurs) ainsi que la manière dont leur nommage dans les prompts permet d’obtenir un code généré par l’IA plus propre.

Éditeur : REACTAPP.TOP12 min de lecture2819 mots

Sources: How I Get AI to Write Cleaner React Code with 4 Architecture Patterns

Des agents de codage basés sur l’IA tels que Claude ou OpenCode peuvent générer une fonctionnalité React fonctionnelle en quelques instructions, et le code fait généralement ce que vous avez demandé. Le point faible réside dans la structure : les composants générés comptent souvent des centaines de lignes, mélangeant du markup, des données, des effets et des gestionnaires dans un seul fichier. Demander à l’agent de « le rendre modulaire » produit généralement des fragments spécifiques à une page plutôt que des éléments véritablement réutilisables. Cet article présente quatre modèles qui résolvent ce problème de structure et, tout aussi important, montre comment les nommer dans vos instructions afin que l’agent construise l’architecture que vous souhaitez plutôt que d’en choisir une à votre place.

Pourquoi un code fonctionnel n’est pas synonyme d’une bonne architecture

Un principe courant du code propre consiste à limiter un composant React à environ 100 lignes, avec 200 comme plafond pratique. Il s’agit d’une heuristique et non d’une règle stricte, mais les fichiers de 500 lignes ou plus sont précisément là où le résultat généré par l’IA dérive lorsqu’on ne fait rien pour y remédier.

La réponse évidente est de demander à l’agent de diviser le contenu. Il obéira généralement en séparant l’interface utilisateur en plusieurs composants plus petits au sein d’un dossier dédié à cette page. Ces éléments sont certes plus petits, mais ils restent liés à une seule fonctionnalité et sont rarement utilisables ailleurs. C’est de la décomposition, pas de la modularité.

Pour un petit projet, cela a à peine d’importance. Pour des logiciels qui doivent évoluer pendant des années, cela signifie que chaque modification affecte du code qui n’a jamais été conçu pour être réutilisé ou étendu. La solution n’est pas d’arrêter d’utiliser l’IA. Il s’agit plutôt de connaître les modèles de conception qui permettent à une base de code React de s’adapter, et d’utiliser ces modèles pour guider l’agent. La génération de code devient de plus en plus peu coûteuse ; c’est votre jugement quant à sa structure qui reste essentiel. Si vous souhaitez en savoir plus sur la manière dont les résultats générés par l’IA ont tendance à ignorer les principes du code propre, consultez quand l’IA écrit votre application React mais ignore les principes du code propre.

Modèle 1 : diviser les sections en quatre composants

Si vous demandez à un développeur de créer une section d’interface utilisateur, il intégrera généralement l’enveloppe, le titre, la mise en forme de la liste et les éléments individuels dans une seule composante. La duplication devient évidente à mesure que l’application se développe, car la même structure réapparaît presque partout : blogs, boutiques en ligne, portfolios, annuaires, pages d’accueil et tableaux de bord SaaS.

Imaginez une page d’accueil de commerce électronique avec des sections pour les produits nouveaux, ceux en vogue et les plus populaires. Chaque section a l’air identique ; seuls les données diffèrent. Chacune peut être divisée en quatre parties, chacune ayant une fonction spécifique :

  • un conteneur de section
  • un titre de section
  • une liste de cartes
  • une carte individuelle

La section

La section est l’élément parent. Elle reçoit des données via des props et les transmet à ses enfants ; elle connaît donc la composition mais pas les détails de mise en forme.

export function ProductSection({ title, subtitle, products }) {
  return (
    <section>
      <SectionHeading
        title={title}
        subtitle={subtitle}
      />

      <ProductList products={products} />
    </section>
  )
}

Le titre

Un composant de titre dédié regroupe le stylage du titre et du sous-titre en un seul endroit, ce qui permet à chaque section de l’application d’avoir un aspect cohérent sans que quiconque ait à recréer le markup.

export function SectionHeading({ title, subtitle }) {
  return (
    <div>
      <h2>{title}</h2>
      <p>{subtitle}</p>
    </div>
  )
}

La liste

La liste a une seule responsabilité : itérer sur la collection et afficher une carte pour chaque élément, en utilisant une clé stable.

export function ProductList({ products }) {
  return (
    <div className="grid">
      {products.map(product => (
        <ProductCard
          key={product.id}
          product={product}
        />
      ))}
    </div>
  )
}

Remarquez qu’elle n’a aucune idée de l’aspect d’un produit. Elle ne sait que comment organiser une liste, ce qui facilite son réutilisation avec une carte différente par la suite.

La carte

La carte affiche un seul élément et rien d’autre.

export function ProductCard({ product }) {
  return (
    <div>
      <img src={product.image} />
      <h3>{product.title}</h3>
      <p>${product.price}</p>
    </div>
  )
}

La présentation est sa seule préoccupation. Dans du code réel, on attribuerait également à l’image un attribut alt significatif, ce que l’exemple minimal omet.

Comparaison des deux approches

Laisser les paramètres par défaut, un agent a tendance à générer quelque chose comme ceci :

function Products({ products }) {
  return (
    <section>
      <h2>Latest Products</h2>

      <div className="grid">
        {products.map(product => (
          <article key={product.id}>
            <img src={product.image} />
            <h3>{product.title}</h3>
            <p>${product.price}</p>
          </article>
        ))}
      </div>
    </section>
  )
}

D’un point de vue technique, il n’y a rien d’anormal. Le problème apparaît plus tard, lorsque une autre page a besoin du même style de titre, du même design de carte ou de la même grille. À ce moment-là, il faut soit dupliquer le code, soit extraire des composants à partir d’une base de code déjà développée autour de ces duplications.

Avec cette séparation en quatre composants, la page elle-même se réduit à des données et à une composition :

function HomePage() {
  return (
    <>
      <ProductSection
        title="Hot Products"
        subtitle="Most popular products right now"
        products={hotProducts}
      />

      <ProductSection
        title="New Products"
        subtitle="Recently added to our store"
        products={newProducts}
      />

      <ProductSection
        title="Trending Products"
        subtitle="What everyone is buying"
        products={trendingProducts}
      />

      <ProductSection
        title="Discounted Products"
        subtitle="Best deals this week"
        products={discountedProducts}
      />
    </>
  )
}

La structure du fichier reste petite et prévisible :

components/
│
├── SectionHeading.tsx
├── ProductCard.tsx
├── ProductList.tsx
└── ProductSection.tsx

Chaque bloc de construction a désormais une place bien définie. Le même titre fonctionne dans n’importe quelle section, une nouvelle ligne de produit correspond à un autre ProductSection, et une page entièrement nouvelle peut être assemblée à partir des mêmes éléments.

Incitation à l’adopter

Au lieu de demander à l’agent de créer une section produit, précisez la structure : « Utilisez ici le modèle UI à 4 composants : une ProductSection qui comprend un SectionHeading et une ProductList, laquelle affiche des éléments ProductCard, chaque composant ayant une seule fonction. » Cette seule phrase modifie la structure du résultat, qui devient bien plus facile à lire, à réutiliser et à maintenir.

Modèle 2 : déplacer la logique dans des hooks personnalisés

La plupart des développeurs React connaissent les hooks personnalisés, mais lorsqu’un agent effectue la saisie, il est facile d’en oublier l’existence. Lorsqu’on leur demande de créer une nouvelle interface, les agents conservent généralement l’état, les effets, les appels API et les gestionnaires d’événements à l’intérieur du composant. Après quelques itérations, le fichier dépasse les 300 lignes en raison du mélange de l’interface utilisateur et de la logique.

Il peut être acceptable de conserver la logique au sein du composant. Cependant, lorsque cette même logique est nécessaire ailleurs ou qu’elle commence à obscurcir ce que le composant affiche, il est généralement préférable d’en extraire un hook. Parmi les exemples courants on trouve :

  • useDebounce() pour différer l’envoi d’une requête après saisie dans un champ de recherche
  • useApi() pour gérer la récupération des données ainsi que les états de chargement et d’erreur
  • useLocalStorage() pour conserver l’état dans le navigateur
  • useClickOutside() pour fermer des menus déroulants ou des modaux lorsque l’utilisateur clique ailleurs
  • usePagination() pour gérer l’état des pages
  • useToggle() pour gérer des états booléens tels qu’un panneau latéral ouvert

Logique de recherche à l’intérieur du composant

Considérez une boîte de recherche de produits. Une version générée typique conserve à l’état tant la requête brute que sa copie débounced, utilise un effet avec un chronomètre pour mettre à jour la valeur débounced, et un second effet pour effectuer une requête chaque fois que cette dernière change :

function SearchProducts() {
  const [query, setQuery] = useState("");
  const [debouncedQuery, setDebouncedQuery] = useState("");

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedQuery(query);
    }, 500);

    return () => clearTimeout(timer);
  }, [query]);

  useEffect(() => {
    if (!debouncedQuery) return;

    fetch(`/api/products?search=${debouncedQuery}`);
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search products..."
    />
  )
}

Cela fonctionne. Mais si trois écrans ont besoin du même comportement de débouncing, il faut soit le recopier trois fois, soit en payer plus tard, tant en termes de temps que de ressources, pour l’extraire. Deux remarques concernant la robustesse s’appliquent également : la requête doit être passée par encodeURIComponent avant d’être insérée dans une URL, et une véritable requête doit gérer les réponses hors ordre, par exemple à l’aide d’un AbortController lors du nettoyage de l’effet.

Extraction d’un hook de débouncing

La logique du chronomètre est transférée dans son propre hook qui prend une valeur et un délai, puis retourne la valeur débounced :

// hooks/useDebounce.ts

export function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(timer)
  }, [value, delay])

  return debouncedValue
}

La fonction de nettoyage efface le compteur temporel en attente chaque fois que la valeur change avant l’expiration du délai, ce qui fait de cela un mécanisme d’atténuation des clics plutôt qu’un simple délai. L’inclusion de delay dans l’array des dépendances permet au hook de fonctionner correctement si quelqu’un le modifie.

Le composant se réduit alors à sa véritable fonction :

function SearchProducts() {
  const [query, setQuery] = useState("")

  const debouncedQuery = useDebounce(query)

  useEffect(() => {
    if (!debouncedQuery) return

    fetch(`/api/products?search=${debouncedQuery}`)
  }, [debouncedQuery])

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
    />
  );
}

Le rendu reste dans le composant et l’atténuation des clics se trouve dans le hook, chacun ayant une responsabilité bien définie.

Quand un hook est utile

Toute appel à useState() ne mérite pas forcément son propre hook. Les bons critères sont :

  • la même logique apparaît ou apparaîtra dans plusieurs composants ;
  • le composant devient trop long ;
  • la fonctionnalité combine plusieurs appels à useState(), des effets ou des gestionnaires d’événements.
  • Les appels API, les formulaires, la recherche, la pagination, l’authentification ou le code de stockage encombrent la logique de rendu ;
  • vous souhaitez tester cette logique indépendamment de l’interface utilisateur.
  • Indiquer clairement cette exigence

    Puisque les agents ont tendance par défaut à intégrer la logique directement dans le code, précisez-le explicitement : « Placiez le comportement réutilisable de cette fonctionnalité dans des hooks personnalisés, tels que useApi(), useDebounce() ou useLocalStorage(), et laissez le composant se charger uniquement du markup. » Cette règle s’applique aux quatre modèles : plus vous fournissez de contexte architectural, meilleur sera le code généré.

    Modèle 3 : un composant polymorphe plutôt que des quasi-dupliques

    L’objectif des composants réutilisables n’est pas d’en avoir davantage, mais d’éviter les doublons. Les agents produisent fréquemment des familles de composants de ce type :

    Button.tsx
    LinkButton.tsx
    IconButton.tsx
    

    Ces éléments partagent presque tous leurs styles. Ce qui diffère, c’est l’élément qu’ils affichent : parfois un véritable <button>, parfois une ancre, parfois un lien de routage, parfois un contrôle ne contenant que des icônes. Le modèle polymorphe remplace cette famille d’éléments par un seul composant capable d’être affiché sous la forme de l’élément requis par la situation.

    La version dupliquée

    Une paire générée typique ressemble à ceci :

    // Button.tsx
    export function Button({ children }) {
      return (
        <button className="btn">
          {children}
        </button>
      )
    }
    
    // LinkButton.tsx
    export function LinkButton({ href, children }) {
      return (
        <a href={href} className="btn">
          {children}
        </a>
      )
    }
    

    Les styles sont répétés uniquement parce que l’élément HTML sous-jacent a changé.

    Un bouton polymorphe en TypeScript

    La version saisie accepte une propriété optionnelle as, dont le type est un ElementType générique par défaut égal à "button". Ses autres propriétés sont dérivées de l’élément ou du composant auquel as fait référence, en utilisant ComponentPropsWithoutRef, avec as lui-même omis pour éviter tout conflit.

    type ButtonProps<E extends ElementType = "button"> = {
      as?: E
      children: ReactNode
      variant?: "primary" | "outline"
    } & Omit<React.ComponentPropsWithoutRef<E>, "as">
    
    const Button = <E extends ElementType = "button">({
      as,
      children,
      variant = "primary",
      ...rest
    }: ButtonProps<E>) => {
      const Component = as || "button"
    
      return (
        <Component {...rest}>
          {children}
        </Component>
      )
    }
    

    Puisque les types de propriétés suivent le modèle E, passer as="a" rend la propriété href valide, et passer un élément Link du routage rend sa propriété to valide, tout cela avec une vérification de type. L’exemple est simplifié : variant est accepté mais pas encore appliqué, donc une version complète le mapperait à un nom de classe et fusionnerait toute className fournie. Il est également judicieux de définir par défaut type="button" lors de l’affichage d’un bouton natif, afin qu’il ne soumette pas accidentellement le formulaire qui l’entoure.

    Ce même composant couvre désormais tous les cas :

    <Button>
      Save
    </Button>
    
    <Button
      variant="outline"
    >
      Cancel
    </Button>
    
    <Button
      as="a"
      href="/pricing"
    >
      Pricing
    </Button>
    
    <Button
      as={Link}
      to="/dashboard"
    >
      Dashboard
    </Button>
    
    <Button
      as="button"
      onClick={handleDelete}
    >
      Delete
    </Button>
    

    Les options « Enregistrer », « Annuler », un lien de tarification, une route de tableau de bord et une action de suppression partagent tous le même design ; seul l’élément affiché ou le comportement change.

    Quand l’utiliser

    Un test simple : si l’interface reste identique mais que l’élément sous-jacent change, le composant est un bon candidat pour la polymorphie. Exemples courants :

    • des boutons qui agissent parfois comme des liens
    • des composants typographiques qui affichent h1, h2, p ou span
    • des cartes qui sont parfois un article et parfois un lien cliquable
    • des conteneurs de mise en page qui s’affichent comme section, div ou main

    Ce n’est pas une préférence particulière. Des bibliothèques de composants telles que Radix UI, Chakra UI et Flexy UI s’appuient fortement sur cette même idée pour créer des systèmes de conception flexibles.

    Encourager cette approche

    Les agents ont tendance à créer un nouveau composant pour chaque petite variation, alors donnez-leur le schéma en indiquant son nom : « Écrivez un Button polymorphe qui accepte une propriété as et peut afficher un button natif, une balise a ou un Link de React Router ; gérez les variantes et les tailles à l’intérieur de ce seul composant plutôt que de créer de nouveaux composants. » Vous obtiendrez ainsi moins de composants, moins de doublons et un système de conception plus cohérent.

    Schéma 4 : consolider les états liés avec un réducteur

    C’est probablement le schéma que les agents négligent le plus. Leur approche par défaut consiste à utiliser un useState() distinct pour chaque valeur, ce qui convient bien aux composants simples. Cependant, à mesure que la fonctionnalité s’enrichit, vous finissez par gérer cinq ou six états qui dépendent les uns des autres :

    const [loading, setLoading] = useState(false);
    const [products, setProducts] = useState([]);
    const [error, setError] = useState(null);
    const [page, setPage] = useState(1);
    const [search, setSearch] = useState("");
    const [sortBy, setSortBy] = useState("latest");
    

    Lorsque la pagination, le filtrage, le tri, le chargement et la gestion des erreurs sont tous en jeu, une action de l’utilisateur doit généralement mettre à jour plusieurs d’entre eux en même temps, ce qui rend la composante difficile à suivre.

    Setters coordonnés

    Voici à quoi ressemble une requête avec des états séparés :

    setLoading(true)
    
    try {
      const data = await fetchProducts()
    
      setProducts(data)
      setError(null)
    } catch {
      setError("Something went wrong")
    } finally {
      setLoading(false)
    }
    

    Plusieurs setters s’exécutent en même temps pour un seul événement logique. Ce regroupement est le signe classique qu’un réducteur permettrait d’exprimer cette logique de manière plus efficace.

    Modélisation des transitions en actions

    Avec un réducteur, vous décrivez une seule fois la structure de l’état et vous listez les transitions qu’il peut subir. La syntaxe vous semblera familière si vous avez déjà utilisé Redux :

    const initialState = {
      loading: false,
      products: [],
      error: null,
    };
    
    function reducer(state, action) {
      switch (action.type) {
        case "FETCH_START":
          return {
            ...state,
            loading: true,
          };
    
        case "FETCH_SUCCESS":
          return {
            loading: false,
            products: action.payload,
            error: null,
          };
    
        case "FETCH_ERROR":
          return {
            ...state,
            loading: false,
            error: action.payload,
          };
    
        default:
          return state;
      }
    }
    

    FETCH_START active le drapeau de chargement, FETCH_SUCCESS enregistre les produits et efface à la fois le drapeau de chargement et l’erreur, tandis que FETCH_ERROR consigne l’erreur. Dans le cas de succès, un nouvel objet complet est retourné ; si vous ajoutez par la suite des champs tels que page ou sortBy à l’état, n’oubliez pas d’utiliser ...state également, sinon ces valeurs seront ignorées.

    La composante envoie ensuite des actions au lieu d’appeler des fonctions de mise à jour :

    const [state, dispatch] = useReducer(
      reducer,
      initialState
    )
    
    dispatch({ type: "FETCH_START" })
    
    dispatch({
      type: "FETCH_SUCCESS",
      payload: products,
    })
    

    Toute mise à jour passe désormais par une seule fonction. Pour comprendre le comportement de la fonctionnalité, il suffit de lire le réducteur au lieu de chercher des appels à setState dans toute la composante, et comme le réducteur est une fonction pure, il est facile à tester de manière unitaire.

    Lorsqu’un réducteur est adapté

    Les réducteurs ne conviennent pas à toutes les composantes. Ils s’avèrent utiles lorsque :

    • plusieurs changements d’état se produisent en même temps ;
    • la fonctionnalité comporte de nombreuses actions distinctes ;
    • il devient difficile de suivre les transitions d’état ;
    • la fonctionnalité correspond à un panier, à un guide en plusieurs étapes, à un flux de connexion, à un formulaire volumineux ou à un tableau de bord chargé.

    Pour un modal ou un interrupteur, l’utilisation simple de useState() reste la meilleure option.

    Suggestion pour y parvenir

    Sans orientation, un agent produira presque toujours une série d’appels à useState(). Demandez-lui la structure souhaitée : « Implémentez ceci avec useReducer : définissez des types d’actions nommés, placez chaque changement d’état à l’intérieur de la fonction réductrice, et faites en sorte que le composant ne fasse rien d’autre que de rendre l’interface et d’envoyer des actions. » Plus la fonctionnalité est complexe, plus cette approche s’avère efficace.

    Points clés

    • Les agents d’IA génèrent de manière fiable du code React fonctionnel, mais ont tendance à utiliser des composants monolithiques, de la logique inline, des variantes presque identiques et un état dispersé.
    • Quatre patterns permettent de remédier à ces tendances : la composition par sections, les hooks personnalisés, les composants polymorphes et les reducers.
    • Chaque pattern possède un déclencheur clair ; il faut l’appliquer lorsque ce déclencheur apparaît, et non par réflexe.
    • Préciser le nom du pattern ainsi que les éléments attendus dans votre demande est le moyen le plus simple d’obtenir une architecture améliorée de la part de l’agent.
    • La génération de code devient de plus en plus facile chaque année ; décider de sa structure reste cependant la tâche du développeur, et connaître ces patterns est ce qui vous permet de le faire.