ReactNext.jsTypeScriptBonnes pratiques

Anti-patterns React en 2026 : 6 erreurs courantes

Publié le

Dimitri Dumont
Dimitri Dumont

Développeur React & Next.js freelance

Ces six anti-patterns React apparaissent dans la majorité des codebases. Ils provoquent des re-renders inutiles, des bugs de synchronisation et de la complexité accidentelle. Le React Compiler (stable depuis octobre 2025) automatise la mémoïsation, mais ne corrige pas ces erreurs de logique.

Chaque section présente le problème, son impact, et la correction avec un exemple avant/après.

Dans cet article :

  • Les 6 anti-patterns les plus fréquents, avec code avant/après
  • Pourquoi chaque pattern pose problème (re-renders, bugs, complexité)
  • Les alternatives modernes (React 19, Server Components)
  • Un diagnostic rapide pour détecter ces patterns dans votre codebase

1. Calculer l'état dérivé dans un useEffect

Le problème

C'est l'anti-pattern le plus répandu. Un composant stocke dans le state une valeur qui pourrait être calculée à partir des props ou d'un autre state.

src/components/cart-summary.tsx
// ❌ Anti-pattern : état dérivé synchronisé dans useEffect
const CartSummary = ({ items }: { items: CartItem[] }) => {
  const [total, setTotal] = useState(0)
 
  useEffect(() => {
    setTotal(items.reduce((sum, item) => sum + item.price * item.quantity, 0))
  }, [items])
 
  return <p>Total : {total.toFixed(2)} €</p>
}

Ce code provoque deux renders à chaque changement de items :

  1. Un premier render avec l'ancien total (stale)
  2. Le useEffect s'exécute, appelle setTotal, et déclenche un second render

Le composant affiche brièvement un montant incorrect entre les deux renders.

La correction

Calculer la valeur directement pendant le render. React ré-exécute la fonction composant à chaque changement de props ou state.

src/components/cart-summary.tsx
// ✅ Calcul direct pendant le render
const CartSummary = ({ items }: { items: CartItem[] }) => {
  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
 
  return <p>Total : {total.toFixed(2)} €</p>
}

Un seul render, pas de state intermédiaire, pas de désynchronisation.

Si le calcul est coûteux

Pour les opérations coûteuses (calculs sur de longues listes, transformations complexes), useMemo évite de recalculer à chaque render :

src/components/cart-summary.tsx
// ✅ Calcul mémoïsé pour les opérations coûteuses
const CartSummary = ({ items }: { items: CartItem[] }) => {
  const total = useMemo(
    () => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
    [items],
  )
 
  return <p>Total : {total.toFixed(2)} €</p>
}

useMemo ne recalcule que si items change. Le React Compiler (opt-in dans Next.js 16 via reactCompiler: true) automatise cette mémoïsation. Avec le Compiler activé, useMemo explicite devient redondant dans la plupart des cas.

Comment détecter cet anti-pattern

Signal d'alerte : un useEffect qui appelle un setter du même composant avec une valeur calculée à partir des deps.

// 🚩 Signal : useEffect + setState avec valeur dérivée
useEffect(() => {
	setSomething(computeFrom(dep1, dep2))
}, [dep1, dep2])

Deux règles ESLint détectent automatiquement ce pattern :

2. Utiliser useState quand useRef suffit

Le problème

useState déclenche un re-render à chaque mise à jour. Quand la valeur stockée n'affecte pas le rendu visuel, ce re-render est inutile.

src/components/stopwatch.tsx
// ❌ Anti-pattern : useState pour une valeur qui n'affecte pas le rendu
const Stopwatch = () => {
  const [startTime, setStartTime] = useState<number | null>(null)
  const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null)
  const [now, setNow] = useState<number | null>(null)
 
  const handleStart = () => {
    const start = Date.now()
    setStartTime(start)
    setNow(start)
 
    // ❌ Stocker l'interval ID dans le state provoque un re-render inutile
    setIntervalId(
      setInterval(() => {
        setNow(Date.now())
      }, 10),
    )
  }
 
  const handleStop = () => {
    if (intervalId) clearInterval(intervalId)
  }
 
  const elapsed = startTime && now ? (now - startTime) / 1000 : 0
 
  return (
    <div>
      <p>{elapsed.toFixed(2)}s</p>
      <button onClick={handleStart}>Démarrer</button>
      <button onClick={handleStop}>Arrêter</button>
    </div>
  )
}

intervalId ne s'affiche nulle part. Le stocker dans useState ajoute du state inutile au composant : React le suit, le compare à chaque render, et le sérialise dans les outils de debug. Ce n'est pas du state UI, c'est un détail d'implémentation.

La correction

useRef persiste la valeur entre les renders sans l'intégrer au state du composant.

src/components/stopwatch.tsx
// ✅ useRef pour les valeurs qui ne s'affichent pas
const Stopwatch = () => {
  const [startTime, setStartTime] = useState<number | null>(null)
  const [now, setNow] = useState<number | null>(null)
  const intervalRef = useRef<NodeJS.Timeout | null>(null)
 
  const handleStart = () => {
    const start = Date.now()
    setStartTime(start)
    setNow(start)
 
    intervalRef.current = setInterval(() => {
      setNow(Date.now())
    }, 10)
  }
 
  const handleStop = () => {
    if (intervalRef.current) clearInterval(intervalRef.current)
  }
 
  const elapsed = startTime && now ? (now - startTime) / 1000 : 0
 
  return (
    <div>
      <p>{elapsed.toFixed(2)}s</p>
      <button onClick={handleStart}>Démarrer</button>
      <button onClick={handleStop}>Arrêter</button>
    </div>
  )
}

Règle de décision

La valeur s'affiche dans le JSX ?Hook à utiliser
OuiuseState
NonuseRef

Cas courants pour useRef :

  • Timer IDs (setTimeout, setInterval)
  • Références DOM
  • Valeurs précédentes (previous props/state)
  • Compteurs internes (nombre d'appels API, tentatives)
  • Instances de classes externes (IntersectionObserver, WebSocket)

3. Copier les props dans le state

Le problème

Dupliquer une prop dans le state local crée deux sources de vérité. Le state local diverge silencieusement quand la prop change.

src/components/user-profile.tsx
// ❌ Anti-pattern : copier une prop dans le state
const UserProfile = ({ user }: { user: User }) => {
  const [name, setName] = useState(user.name)
 
  // ❌ Le state initial est capturé une seule fois.
  // Si `user.name` change, `name` garde l'ancienne valeur.
 
  return <p>{name}</p>
}

useState(user.name) capture la valeur au premier render uniquement. Les mises à jour suivantes de user.name sont ignorées.

La correction

Utiliser la prop directement :

src/components/user-profile.tsx
// ✅ Utiliser la prop directement
const UserProfile = ({ user }: { user: User }) => {
  return <p>{user.name}</p>
}

Exception : formulaire d'édition

Le seul cas valide est un formulaire où l'utilisateur modifie une copie locale avant de sauvegarder :

src/components/edit-user-form.tsx
// ✅ Exception : copie locale intentionnelle pour un formulaire d'édition
const EditUserForm = ({ user }: { user: User }) => {
  const [draft, setDraft] = useState(user.name)
 
  // Réinitialiser si l'utilisateur source change
  // (voir section 5 pour une meilleure approche avec `key`)
 
  return (
    <form>
      <input value={draft} onChange={(e) => setDraft(e.target.value)} />
      <button type="submit">Sauvegarder</button>
    </form>
  )
}

Dans ce cas, la divergence est intentionnelle : l'utilisateur modifie un brouillon local.

4. Gérer les événements utilisateur dans un useEffect

Le problème

Réagir à une action utilisateur (clic, soumission) via useEffect au lieu d'un handler direct. L'intention est perdue et le code devient difficile à suivre.

src/components/export-button.tsx
// ❌ Anti-pattern : événement utilisateur géré dans useEffect
const ExportButton = ({ reportId }: { reportId: string }) => {
  const [shouldExport, setShouldExport] = useState(false)
 
  useEffect(() => {
    if (shouldExport) {
      fetch("/api/reports/export", {
        method: "POST",
        body: JSON.stringify({ reportId }),
      })
      setShouldExport(false)
    }
  }, [shouldExport, reportId])
 
  return <button onClick={() => setShouldExport(true)}>Exporter</button>
}

Problèmes :

  • L'appel API est déclenché par un changement de state, pas par l'événement
  • Si reportId change pendant que shouldExport est true, l'effet se ré-exécute et exporte le mauvais rapport
  • Le flag shouldExport n'a aucune raison d'exister

La correction

L'action dans le handler, directement. Pour la gestion d'erreurs dans ces handlers, le Result Pattern offre une alternative explicite au try/catch.

src/components/export-button.tsx
// ✅ Action dans le handler
const ExportButton = ({ reportId }: { reportId: string }) => {
  const handleExport = async () => {
    await fetch("/api/reports/export", {
      method: "POST",
      body: JSON.stringify({ reportId }),
    })
  }
 
  return <button onClick={handleExport}>Exporter</button>
}

La documentation React est explicite : les effets servent à synchroniser avec des systèmes externes (DOM, API tierces, WebSockets). Un clic utilisateur n'est pas un système externe.

Règle de décision

DéclencheurOù mettre la logique
Action utilisateur (clic, soumission, keypress)Event handler
Changement de props/state qui doit se synchroniser avec un système externeuseEffect
Montage/démontage du composant (setup/cleanup)useEffect

5. Réinitialiser le state avec useEffect sur les props

Le problème

Quand un composant reçoit un nouvel ID ou une nouvelle entité, le state local doit être réinitialisé. Le réflexe courant est d'utiliser un useEffect :

src/components/comment-form.tsx
// ❌ Anti-pattern : réinitialiser le state via useEffect
const CommentForm = ({ postId }: { postId: string }) => {
  const [comment, setComment] = useState("")
 
  useEffect(() => {
    setComment("")
  }, [postId])
 
  return (
    <form>
      <textarea value={comment} onChange={(e) => setComment(e.target.value)} />
      <button type="submit">Commenter</button>
    </form>
  )
}

Même problème que l'anti-pattern #1 : deux renders (un avec l'ancien commentaire, un après le reset). Le formulaire affiche brièvement le texte du post précédent.

La correction

La prop key de React force le démontage et le remontage du composant, réinitialisant tout son state interne :

src/components/post-page.tsx
// ✅ Utiliser key pour réinitialiser le state
const PostPage = ({ postId }: { postId: string }) => {
  return (
    <div>
      <PostContent postId={postId} />
      <CommentForm key={postId} />
    </div>
  )
}
 
const CommentForm = () => {
  const [comment, setComment] = useState("")
 
  return (
    <form>
      <textarea value={comment} onChange={(e) => setComment(e.target.value)} />
      <button type="submit">Commenter</button>
    </form>
  )
}

Quand postId change, React démonte l'ancien CommentForm et en monte un nouveau avec un state vierge. Un seul render, pas de flash.

Le composant CommentForm n'a plus besoin de connaître postId. Sa responsabilité est réduite (principe SRP).

Contrepartie : key force le démontage et le remontage du sous-arbre DOM complet. Pour un formulaire simple, le coût est négligeable. Pour un composant avec un DOM lourd (éditeur riche, tableau complexe), mesurer avec le React DevTools Profiler avant d'adopter ce pattern.

6. Chaîner les useEffect pour synchroniser des states

Le problème

Plusieurs useEffect qui se déclenchent en cascade, chaque setter en déclenchant un autre :

src/components/shipping-form.tsx
// ❌ Anti-pattern : cascade de useEffect
const ShippingForm = () => {
  const [country, setCountry] = useState("FR")
  const [city, setCity] = useState("")
  const [zipCode, setZipCode] = useState("")
 
  // Quand le pays change, réinitialiser la ville
  useEffect(() => {
    setCity("")
  }, [country])
 
  // Quand la ville change, réinitialiser le code postal
  useEffect(() => {
    setZipCode("")
  }, [city])
 
  return (
    <form>
      <select value={country} onChange={(e) => setCountry(e.target.value)}>
        <option value="FR">France</option>
        <option value="BE">Belgique</option>
      </select>
      <input
        value={city}
        onChange={(e) => setCity(e.target.value)}
        placeholder="Ville"
      />
      <input
        value={zipCode}
        onChange={(e) => setZipCode(e.target.value)}
        placeholder="Code postal"
      />
    </form>
  )
}

Un changement de pays provoque trois renders : country change, city se réinitialise, zipCode se réinitialise. Chaque useEffect ajoute un render supplémentaire.

La correction

Réinitialiser dans le handler d'événement, là où le changement est initié :

src/components/shipping-form.tsx
// ✅ Réinitialisation dans le handler
const ShippingForm = () => {
  const [country, setCountry] = useState("FR")
  const [city, setCity] = useState("")
  const [zipCode, setZipCode] = useState("")
 
  const handleCountryChange = (newCountry: string) => {
    setCountry(newCountry)
    setCity("")
    setZipCode("")
  }
 
  const handleCityChange = (newCity: string) => {
    setCity(newCity)
    setZipCode("")
  }
 
  return (
    <form>
      <select
        value={country}
        onChange={(e) => handleCountryChange(e.target.value)}
      >
        <option value="FR">France</option>
        <option value="BE">Belgique</option>
      </select>
      <input
        value={city}
        onChange={(e) => handleCityChange(e.target.value)}
        placeholder="Ville"
      />
      <input
        value={zipCode}
        onChange={(e) => setZipCode(e.target.value)}
        placeholder="Code postal"
      />
    </form>
  )
}

Un seul render par changement. La logique de réinitialisation est explicite et localisée.

Les erreurs à éviter

Récapitulatif des signaux d'alerte dans votre codebase :

Signal d'alerteAnti-pattern probableSection
useEffect + setState avec une valeur calculée à partir des depsÉtat dérivé (#1)1
useState pour une valeur qui n'apparaît pas dans le JSXuseState au lieu de useRef (#2)2
useState(props.something) dans l'initialisationProps copiées dans le state (#3)3
Un flag boolean shouldDoX qui déclenche un useEffectÉvénement dans useEffect (#4)4
useEffect(() => { setState("") }, [someId])Reset via useEffect au lieu de key (#5)5
Plusieurs useEffect dont les deps se chevauchentCascade de synchronisation (#6)6
useEffect qui appelle un callback parent (onDataChange(data))Remontée de state/data vers le parent

Autres anti-patterns à connaître

Les six anti-patterns ci-dessus couvrent les cas les plus fréquents, mais d'autres patterns useEffect méritent attention :

Anti-patternProblèmeCorrection
Remonter du state vers le parent via callback dans useEffectFlux de données bidirectionnel, re-renders en cascadeRemonter le state dans le parent (lift state up)
Fetch dans l'enfant puis remontée via useEffect + callbackL'enfant contrôle des données qui ne lui appartiennent pasDéplacer le fetch dans le parent et passer les données en props
Passer une ref au parent via useEffectFuite de détails d'implémentation entre composantsAccepter ref comme prop (React 19+)

Le plugin eslint-plugin-react-you-might-not-need-an-effect détecte automatiquement ces patterns (no-pass-live-state-to-parent, no-pass-data-to-parent, no-pass-ref-to-parent).

FAQ

useEffect est-il à éviter complètement ?

Non. useEffect reste nécessaire pour synchroniser avec des systèmes externes : connexion WebSocket, abonnement à un event bus, intégration d'une librairie non-React (carte, éditeur), setup/cleanup au montage. La règle est simple : si la logique ne concerne pas un système externe, il existe probablement une alternative sans useEffect. La documentation React détaille les cas légitimes.

Le React Compiler rend-il useMemo inutile ?

Dans la plupart des cas, oui. Le React Compiler (v1.0 stable depuis octobre 2025, opt-in dans Next.js 16 via reactCompiler: true) applique automatiquement la mémoïsation au build. Les useMemo et useCallback explicites deviennent redondants pour le nouveau code. En revanche, les anti-patterns décrits dans cet article (état dérivé dans useEffect, props dans le state) ne sont pas corrigés par le Compiler. Ce sont des erreurs de logique, pas d'optimisation.

Comment détecter ces anti-patterns dans une codebase existante ?

Trois approches complémentaires : les règles ESLint décrites dans la section 1 (set-state-in-effect du plugin officiel, eslint-plugin-react-you-might-not-need-an-effect communautaire) détectent automatiquement les cas les plus courants. Les composants avec 3+ useEffect sont les premiers candidats à un refactoring.

Ces corrections s'appliquent-elles avec React Server Components ?

Oui, et elles deviennent encore plus importantes. Les Server Components exécutent le render côté serveur, où useEffect ne s'exécute pas. Migrer vers du calcul direct (anti-pattern #1) produit des fonctions pures sans hooks, ce qui facilite leur extraction vers des Server Components. En revanche, les handlers (anti-pattern #4) restent des fonctionnalités client : un composant avec onClick nécessite 'use client' au même titre qu'un composant avec useEffect. Le vrai critère est l'absence totale d'interactivité (pas de hooks, pas de handlers, pas d'API navigateur).

Faut-il refactorer tous les useEffect d'un coup ?

Non. Prioriser par impact : commencer par les composants les plus re-rendus (React DevTools Profiler), puis les composants avec le plus de useEffect. Un composant avec un seul useEffect pour du data fetching fonctionne correctement. Les cascades (anti-pattern #6) et les états dérivés (anti-pattern #1) sont les corrections les plus rentables. Les composants sans useEffect sont aussi plus simples à tester.

Conclusion

Points clés

  1. L'état dérivé est l'anti-pattern le plus fréquent et le plus facile à corriger : remplacer useEffect + setState par un calcul direct
  2. useState vs useRef : si la valeur ne s'affiche pas, c'est un useRef
  3. Les événements utilisateur vont dans les handlers, pas dans les useEffect
  4. La prop key réinitialise le state sans useEffect
  5. Le React Compiler automatise useMemo/useCallback, mais ne corrige pas les erreurs de logique

Checklist de détection rapide

  • Rechercher les useEffect qui appellent un setter avec une valeur dérivée des deps
  • Vérifier que les useState stockent des valeurs affichées dans le JSX
  • Identifier les composants avec 3+ useEffect
  • Vérifier l'absence de props copiées dans le state (useState(props.x))
  • Chercher les useEffect qui appellent un callback parent avec du state local
Dimitri Dumont

À propos de l'auteur

Je suis Dimitri Dumont, développeur freelance spécialisé React & Next.js depuis plus de 7 ans. J'ai accompagné 22 startups et réalisé 43 missions avec une note de 5/5. J'applique ces patterns au quotidien pour continuer de livrer rapidement du code évolutif. En savoir plus →

Cet article vous a été utile ?

Je peux vous accompagner sur votre projet React & Next.js.

Discutons de votre projet →

Articles similaires