React Hooks avancés : Patterns et optimisations

Maîtrisez les Hooks React avancés avec des patterns éprouvés. Custom hooks, useEffect optimisé, useMemo, useCallback et techniques de performance.

Illustration des React Hooks avancés avec des patterns de composition et d'optimisation

Les Hooks ont révolutionné React en permettant d'utiliser l'état et les effets dans les composants fonctionnels. Au-delà des usages basiques de useState et useEffect, des patterns avancés permettent de créer du code réutilisable, performant et maintenable.

Prérequis

Ce guide suppose une connaissance des Hooks de base (useState, useEffect, useContext). Les exemples utilisent React 18+ et TypeScript pour un typage robuste.

Maîtriser useEffect : éviter les pièges courants

Le hook useEffect est souvent mal compris et mal utilisé. Une compréhension approfondie de son fonctionnement permet d'éviter les bugs subtils et les problèmes de performance.

Le principe fondamental : useEffect synchronise un composant avec un système externe. Si l'effet ne communique pas avec l'extérieur (API, DOM, timer), il est probablement inutile.

hooks/useDocumentTitle.tstsx
// Custom hook pour synchroniser le titre du document
import { useEffect } from 'react'

export function useDocumentTitle(title: string) {
  useEffect(() => {
    // Sauvegarder le titre précédent
    const previousTitle = document.title
    // Mettre à jour le titre
    document.title = title

    // Cleanup : restaurer le titre précédent au démontage
    return () => {
      document.title = previousTitle
    }
  }, [title]) // Se déclenche uniquement si title change
}

L'erreur la plus courante consiste à omettre le tableau de dépendances ou à y inclure des valeurs instables. Chaque valeur utilisée dans l'effet doit figurer dans les dépendances.

components/UserProfile.tsxtsx
'use client'

import { useEffect, useState } from 'react'

interface User {
  id: string
  name: string
  email: string
}

interface UserProfileProps {
  userId: string
}

export function UserProfile({ userId }: UserProfileProps) {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    // Flag pour éviter les mises à jour après démontage
    let isMounted = true
    // Controller pour annuler les requêtes en cours
    const controller = new AbortController()

    async function fetchUser() {
      setLoading(true)
      setError(null)

      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal
        })

        if (!response.ok) {
          throw new Error('Utilisateur non trouvé')
        }

        const data = await response.json()

        // Vérifier que le composant est toujours monté
        if (isMounted) {
          setUser(data)
        }
      } catch (err) {
        // Ignorer les erreurs d'annulation
        if (err instanceof Error && err.name !== 'AbortError') {
          if (isMounted) {
            setError(err.message)
          }
        }
      } finally {
        if (isMounted) {
          setLoading(false)
        }
      }
    }

    fetchUser()

    // Cleanup : annuler la requête et marquer comme démonté
    return () => {
      isMounted = false
      controller.abort()
    }
  }, [userId]) // Relancer l'effet si userId change

  if (loading) return <div>Chargement...</div>
  if (error) return <div>Erreur : {error}</div>
  if (!user) return null

  return (
    <div className="p-4 border rounded-lg">
      <h2 className="text-xl font-bold">{user.name}</h2>
      <p className="text-gray-600">{user.email}</p>
    </div>
  )
}

Ce pattern avec AbortController et flag isMounted évite les fuites de mémoire et les mises à jour d'état sur des composants démontés.

Créer des custom hooks réutilisables

Les custom hooks encapsulent de la logique réutilisable. Un bon custom hook suit le principe de responsabilité unique et expose une API claire.

hooks/useLocalStorage.tstsx
// Hook générique pour persister l'état dans localStorage
import { useState, useEffect, useCallback } from 'react'

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
  // Initialiser l'état avec la valeur stockée ou la valeur par défaut
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') {
      return initialValue
    }

    try {
      const item = window.localStorage.getItem(key)
      return item ? JSON.parse(item) : initialValue
    } catch (error) {
      console.warn(`Erreur lecture localStorage "${key}":`, error)
      return initialValue
    }
  })

  // Synchroniser avec localStorage à chaque changement
  useEffect(() => {
    if (typeof window === 'undefined') return

    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue))
    } catch (error) {
      console.warn(`Erreur écriture localStorage "${key}":`, error)
    }
  }, [key, storedValue])

  // Fonction pour mettre à jour la valeur
  const setValue = useCallback((value: T | ((prev: T) => T)) => {
    setStoredValue((prev) => {
      const nextValue = value instanceof Function ? value(prev) : value
      return nextValue
    })
  }, [])

  // Fonction pour supprimer la valeur
  const removeValue = useCallback(() => {
    setStoredValue(initialValue)
    if (typeof window !== 'undefined') {
      window.localStorage.removeItem(key)
    }
  }, [key, initialValue])

  return [storedValue, setValue, removeValue]
}

Ce hook peut être utilisé dans n'importe quel composant pour persister des données.

components/ThemeToggle.tsxtsx
'use client'

import { useLocalStorage } from '@/hooks/useLocalStorage'

type Theme = 'light' | 'dark' | 'system'

export function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage<Theme>('theme', 'system')

  return (
    <select
      value={theme}
      onChange={(e) => setTheme(e.target.value as Theme)}
      className="px-3 py-2 border rounded-lg"
    >
      <option value="light">Clair</option>
      <option value="dark">Sombre</option>
      <option value="system">Système</option>
    </select>
  )
}
Convention de nommage

Les custom hooks commencent toujours par "use" (useLocalStorage, useFetch, useDebounce). Cette convention permet à React de vérifier les règles des hooks et aux outils de linting de fonctionner correctement.

Pattern de composition avec useReducer

Pour les états complexes avec plusieurs actions possibles, useReducer offre une structure plus prévisible que plusieurs useState.

hooks/useCart.tstsx
// Hook de gestion de panier avec useReducer
import { useReducer, useCallback, useMemo } from 'react'

interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}

interface CartState {
  items: CartItem[]
  isOpen: boolean
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: Omit<CartItem, 'quantity'> }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
  | { type: 'CLEAR_CART' }
  | { type: 'TOGGLE_CART' }

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existingItem = state.items.find(
        (item) => item.id === action.payload.id
      )

      if (existingItem) {
        // Incrémenter la quantité si l'item existe
        return {
          ...state,
          items: state.items.map((item) =>
            item.id === action.payload.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          )
        }
      }

      // Ajouter un nouvel item
      return {
        ...state,
        items: [...state.items, { ...action.payload, quantity: 1 }]
      }
    }

    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter((item) => item.id !== action.payload)
      }

    case 'UPDATE_QUANTITY':
      return {
        ...state,
        items: state.items.map((item) =>
          item.id === action.payload.id
            ? { ...item, quantity: action.payload.quantity }
            : item
        ).filter((item) => item.quantity > 0)
      }

    case 'CLEAR_CART':
      return { ...state, items: [] }

    case 'TOGGLE_CART':
      return { ...state, isOpen: !state.isOpen }

    default:
      return state
  }
}

const initialState: CartState = {
  items: [],
  isOpen: false
}

export function useCart() {
  const [state, dispatch] = useReducer(cartReducer, initialState)

  // Actions mémorisées pour éviter les re-renders
  const addItem = useCallback(
    (item: Omit<CartItem, 'quantity'>) => {
      dispatch({ type: 'ADD_ITEM', payload: item })
    },
    []
  )

  const removeItem = useCallback((id: string) => {
    dispatch({ type: 'REMOVE_ITEM', payload: id })
  }, [])

  const updateQuantity = useCallback((id: string, quantity: number) => {
    dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } })
  }, [])

  const clearCart = useCallback(() => {
    dispatch({ type: 'CLEAR_CART' })
  }, [])

  const toggleCart = useCallback(() => {
    dispatch({ type: 'TOGGLE_CART' })
  }, [])

  // Valeurs dérivées mémorisées
  const total = useMemo(
    () => state.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    ),
    [state.items]
  )

  const itemCount = useMemo(
    () => state.items.reduce((sum, item) => sum + item.quantity, 0),
    [state.items]
  )

  return {
    items: state.items,
    isOpen: state.isOpen,
    total,
    itemCount,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    toggleCart
  }
}

Cette approche centralise toute la logique du panier et facilite les tests unitaires.

Prêt à réussir tes entretiens React / Next.js ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Optimisation avec useMemo et useCallback

Ces hooks évitent les calculs ou créations de fonctions inutiles. Leur utilisation doit être ciblée : les appliquer partout dégrade les performances au lieu de les améliorer.

components/ProductList.tsxtsx
'use client'

import { useState, useMemo, useCallback, memo } from 'react'

interface Product {
  id: string
  name: string
  price: number
  category: string
  inStock: boolean
}

interface ProductListProps {
  products: Product[]
}

// Composant enfant mémorisé
const ProductCard = memo(function ProductCard({
  product,
  onAddToCart
}: {
  product: Product
  onAddToCart: (id: string) => void
}) {
  console.log(`Render ProductCard: ${product.name}`)

  return (
    <div className="border rounded-lg p-4">
      <h3 className="font-medium">{product.name}</h3>
      <p className="text-gray-600">{product.price}</p>
      <button
        onClick={() => onAddToCart(product.id)}
        disabled={!product.inStock}
        className="mt-2 px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
      >
        {product.inStock ? 'Ajouter' : 'Rupture'}
      </button>
    </div>
  )
})

export function ProductList({ products }: ProductListProps) {
  const [filter, setFilter] = useState('')
  const [sortBy, setSortBy] = useState<'name' | 'price'>('name')
  const [cart, setCart] = useState<string[]>([])

  // useMemo : mémoriser le résultat d'un calcul coûteux
  const filteredAndSortedProducts = useMemo(() => {
    console.log('Calcul filteredAndSortedProducts')

    let result = products

    // Filtrer par nom
    if (filter) {
      result = result.filter((p) =>
        p.name.toLowerCase().includes(filter.toLowerCase())
      )
    }

    // Trier
    result = [...result].sort((a, b) => {
      if (sortBy === 'name') {
        return a.name.localeCompare(b.name)
      }
      return a.price - b.price
    })

    return result
  }, [products, filter, sortBy]) // Recalculer uniquement si ces deps changent

  // useCallback : mémoriser une fonction
  const handleAddToCart = useCallback((productId: string) => {
    setCart((prev) => [...prev, productId])
  }, []) // Dépendances vides : la fonction ne change jamais

  // Statistiques mémorisées
  const stats = useMemo(
    () => ({
      total: filteredAndSortedProducts.length,
      inStock: filteredAndSortedProducts.filter((p) => p.inStock).length,
      avgPrice:
        filteredAndSortedProducts.reduce((sum, p) => sum + p.price, 0) /
          filteredAndSortedProducts.length || 0
    }),
    [filteredAndSortedProducts]
  )

  return (
    <div>
      <div className="mb-4 flex gap-4">
        <input
          type="text"
          value={filter}
          onChange={(e) => setFilter(e.target.value)}
          placeholder="Rechercher..."
          className="px-3 py-2 border rounded-lg"
        />
        <select
          value={sortBy}
          onChange={(e) => setSortBy(e.target.value as 'name' | 'price')}
          className="px-3 py-2 border rounded-lg"
        >
          <option value="name">Nom</option>
          <option value="price">Prix</option>
        </select>
      </div>

      <p className="text-sm text-gray-600 mb-4">
        {stats.total} produits ({stats.inStock} en stock) -
        Prix moyen : {stats.avgPrice.toFixed(2)}      </p>

      <div className="grid grid-cols-3 gap-4">
        {filteredAndSortedProducts.map((product) => (
          <ProductCard
            key={product.id}
            product={product}
            onAddToCart={handleAddToCart}
          />
        ))}
      </div>
    </div>
  )
}
Quand utiliser useMemo/useCallback ?

Ces hooks ajoutent de la complexité. Les utiliser uniquement pour : (1) des calculs réellement coûteux, (2) des props passées à des composants mémorisés avec memo(), (3) des dépendances d'autres hooks.

Hook de debounce pour les recherches

Le debounce évite d'exécuter une action trop fréquemment, typiquement lors de la saisie dans un champ de recherche.

hooks/useDebounce.tstsx
// Hook générique de debounce
import { useState, useEffect } from 'react'

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)

  useEffect(() => {
    // Créer un timer qui met à jour la valeur après le délai
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    // Cleanup : annuler le timer si value change avant le délai
    return () => {
      clearTimeout(timer)
    }
  }, [value, delay])

  return debouncedValue
}
components/SearchInput.tsxtsx
'use client'

import { useState, useEffect } from 'react'
import { useDebounce } from '@/hooks/useDebounce'

interface SearchResult {
  id: string
  title: string
}

export function SearchInput() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<SearchResult[]>([])
  const [loading, setLoading] = useState(false)

  // Debounce la requête de 300ms
  const debouncedQuery = useDebounce(query, 300)

  useEffect(() => {
    // Ne pas rechercher si la requête est vide
    if (!debouncedQuery.trim()) {
      setResults([])
      return
    }

    async function search() {
      setLoading(true)

      try {
        const response = await fetch(
          `/api/search?q=${encodeURIComponent(debouncedQuery)}`
        )
        const data = await response.json()
        setResults(data.results)
      } catch (error) {
        console.error('Erreur de recherche:', error)
      } finally {
        setLoading(false)
      }
    }

    search()
  }, [debouncedQuery]) // Déclenché uniquement quand debouncedQuery change

  return (
    <div className="relative">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Rechercher..."
        className="w-full px-4 py-2 border rounded-lg"
      />

      {loading && (
        <div className="absolute right-3 top-2.5">
          <div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
        </div>
      )}

      {results.length > 0 && (
        <ul className="absolute w-full mt-1 bg-white border rounded-lg shadow-lg">
          {results.map((result) => (
            <li
              key={result.id}
              className="px-4 py-2 hover:bg-gray-100 cursor-pointer"
            >
              {result.title}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

Le composant effectue la recherche uniquement 300ms après que l'utilisateur ait arrêté de taper, évitant des dizaines de requêtes inutiles.

useImperativeHandle pour les refs avancées

Ce hook permet d'exposer des méthodes spécifiques d'un composant enfant au parent via une ref.

components/VideoPlayer.tsxtsx
'use client'

import {
  useRef,
  useImperativeHandle,
  forwardRef,
  useState,
  useCallback
} from 'react'

// Interface des méthodes exposées
export interface VideoPlayerRef {
  play: () => void
  pause: () => void
  seek: (time: number) => void
  getCurrentTime: () => number
}

interface VideoPlayerProps {
  src: string
  poster?: string
}

export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
  function VideoPlayer({ src, poster }, ref) {
    const videoRef = useRef<HTMLVideoElement>(null)
    const [isPlaying, setIsPlaying] = useState(false)

    // Exposer uniquement les méthodes souhaitées au parent
    useImperativeHandle(
      ref,
      () => ({
        play: () => {
          videoRef.current?.play()
          setIsPlaying(true)
        },
        pause: () => {
          videoRef.current?.pause()
          setIsPlaying(false)
        },
        seek: (time: number) => {
          if (videoRef.current) {
            videoRef.current.currentTime = time
          }
        },
        getCurrentTime: () => {
          return videoRef.current?.currentTime ?? 0
        }
      }),
      [] // Pas de dépendances : les méthodes utilisent toujours la ref actuelle
    )

    const handlePlayPause = useCallback(() => {
      if (isPlaying) {
        videoRef.current?.pause()
      } else {
        videoRef.current?.play()
      }
      setIsPlaying(!isPlaying)
    }, [isPlaying])

    return (
      <div className="relative">
        <video
          ref={videoRef}
          src={src}
          poster={poster}
          className="w-full rounded-lg"
          onPlay={() => setIsPlaying(true)}
          onPause={() => setIsPlaying(false)}
        />
        <button
          onClick={handlePlayPause}
          className="absolute bottom-4 left-4 px-4 py-2 bg-black/50 text-white rounded"
        >
          {isPlaying ? 'Pause' : 'Play'}
        </button>
      </div>
    )
  }
)
components/VideoController.tsxtsx
'use client'

import { useRef } from 'react'
import { VideoPlayer, VideoPlayerRef } from './VideoPlayer'

export function VideoController() {
  const playerRef = useRef<VideoPlayerRef>(null)

  const handleSkipForward = () => {
    if (playerRef.current) {
      const currentTime = playerRef.current.getCurrentTime()
      playerRef.current.seek(currentTime + 10)
    }
  }

  return (
    <div className="space-y-4">
      <VideoPlayer
        ref={playerRef}
        src="/videos/demo.mp4"
        poster="/images/poster.jpg"
      />

      <div className="flex gap-2">
        <button
          onClick={() => playerRef.current?.play()}
          className="px-4 py-2 bg-green-600 text-white rounded"
        >
          Play
        </button>
        <button
          onClick={() => playerRef.current?.pause()}
          className="px-4 py-2 bg-red-600 text-white rounded"
        >
          Pause
        </button>
        <button
          onClick={handleSkipForward}
          className="px-4 py-2 bg-blue-600 text-white rounded"
        >
          +10s
        </button>
      </div>
    </div>
  )
}

Ce pattern est particulièrement utile pour les composants médias, formulaires ou toute interface nécessitant un contrôle impératif.

Hook de gestion du cycle de vie des requêtes

Un hook avancé pour gérer les états de chargement, erreurs et données avec typage strict.

hooks/useFetch.tstsx
// Hook générique pour les requêtes HTTP
import { useState, useEffect, useCallback, useRef } from 'react'

interface UseFetchState<T> {
  data: T | null
  loading: boolean
  error: Error | null
}

interface UseFetchOptions {
  immediate?: boolean // Exécuter immédiatement au montage
  onSuccess?: <T>(data: T) => void
  onError?: (error: Error) => void
}

export function useFetch<T>(
  url: string,
  options: UseFetchOptions = {}
) {
  const { immediate = true, onSuccess, onError } = options

  const [state, setState] = useState<UseFetchState<T>>({
    data: null,
    loading: immediate,
    error: null
  })

  // Ref pour éviter les mises à jour après démontage
  const mountedRef = useRef(true)
  // Ref pour le controller d'annulation
  const abortControllerRef = useRef<AbortController | null>(null)

  const execute = useCallback(async () => {
    // Annuler la requête précédente si en cours
    abortControllerRef.current?.abort()
    abortControllerRef.current = new AbortController()

    setState((prev) => ({ ...prev, loading: true, error: null }))

    try {
      const response = await fetch(url, {
        signal: abortControllerRef.current.signal
      })

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }

      const data: T = await response.json()

      if (mountedRef.current) {
        setState({ data, loading: false, error: null })
        onSuccess?.(data)
      }

      return data
    } catch (error) {
      // Ignorer les erreurs d'annulation
      if (error instanceof Error && error.name === 'AbortError') {
        return null
      }

      const err = error instanceof Error ? error : new Error('Erreur inconnue')

      if (mountedRef.current) {
        setState({ data: null, loading: false, error: err })
        onError?.(err)
      }

      return null
    }
  }, [url, onSuccess, onError])

  const reset = useCallback(() => {
    setState({ data: null, loading: false, error: null })
  }, [])

  // Exécution au montage si immediate=true
  useEffect(() => {
    if (immediate) {
      execute()
    }

    return () => {
      mountedRef.current = false
      abortControllerRef.current?.abort()
    }
  }, [execute, immediate])

  return {
    ...state,
    execute,
    reset,
    isIdle: !state.loading && !state.data && !state.error
  }
}

Ce hook offre une API complète pour gérer n'importe quelle requête avec gestion automatique des annulations et du cycle de vie.

Prêt à réussir tes entretiens React / Next.js ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Conclusion

Les patterns avancés des React Hooks permettent de créer du code maintenable et performant. Les points essentiels :

  • useEffect : toujours inclure le cleanup et gérer les race conditions avec AbortController
  • Custom hooks : encapsuler la logique réutilisable avec une API claire
  • useReducer : privilégier pour les états complexes avec plusieurs actions
  • useMemo/useCallback : utiliser de manière ciblée, uniquement quand nécessaire
  • useDebounce : limiter les exécutions fréquentes pour les recherches
  • useImperativeHandle : exposer des méthodes impératives de manière contrôlée

La maîtrise de ces patterns distingue les développeurs React intermédiaires des experts. Chaque hook résout un problème spécifique : le choix du bon outil au bon moment est la clé d'une architecture React robuste.

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#react hooks
#useEffect
#custom hooks
#performance
#react patterns

Partager

Articles similaires