React - Hooks Avancés

Ces hooks sont moins utilisés au quotidien, mais quand tu en as besoin, ils sont indispensables ! Performance, layout, identifiants uniques... tout y est.


useLayoutEffect - Synchrone et Bloquant

Différence avec useEffect

import { useState, useEffect, useLayoutEffect, useRef } from 'react'

function LayoutEffectDemo() {
  const [count, setCount] = useState(0)
  const [height, setHeight] = useState(0)
  const divRef = useRef()

  // ⚠️ useEffect - S'exécute APRÈS le paint (asynchrone)
  useEffect(() => {
    console.log('📐 useEffect - APRÈS paint')
    // Peut causer un "flash" visuel si on modifie le DOM
  })

  // ✅ useLayoutEffect - S'exécute AVANT le paint (synchrone)
  useLayoutEffect(() => {
    console.log('📏 useLayoutEffect - AVANT paint')
    
    if (divRef.current) {
      const rect = divRef.current.getBoundingClientRect()
      setHeight(rect.height)
    }
  }) // Bloque le paint jusqu'à ce que ce code soit exécuté !

  return (
    <div>
      <h3>useLayoutEffect Demo</h3>
      
      <div
        ref={divRef}
        style={{
          padding: '20px',
          border: '2px solid #007bff',
          backgroundColor: '#f8f9fa',
          // La hauteur change dynamiquement
          fontSize: count > 5 ? '24px' : '16px'
        }}
      >
        <p>Count: {count}</p>
        <p>Ce div fait {height}px de haut</p>
        <p>Contenu qui peut changer de taille...</p>
      </div>

      <button onClick={() => setCount(c => c + 1)}>
        Incrémenter
      </button>
    </div>
  )
}

Cas d'Usage : Éviter le Flash

function TooltipWithLayoutEffect() {
  const [isVisible, setIsVisible] = useState(false)
  const [position, setPosition] = useState({ top: 0, left: 0 })
  const tooltipRef = useRef()
  const triggerRef = useRef()

  // ✅ Calculer la position AVANT le paint pour éviter le flash
  useLayoutEffect(() => {
    if (isVisible && tooltipRef.current && triggerRef.current) {
      const triggerRect = triggerRef.current.getBoundingClientRect()
      const tooltipRect = tooltipRef.current.getBoundingClientRect()
      
      // Calculer la position optimale
      let top = triggerRect.bottom + 8
      let left = triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2)
      
      // Vérifier les débordements d'écran
      if (left + tooltipRect.width > window.innerWidth) {
        left = window.innerWidth - tooltipRect.width - 8
      }
      if (left < 8) {
        left = 8
      }
      if (top + tooltipRect.height > window.innerHeight) {
        top = triggerRect.top - tooltipRect.height - 8
      }

      setPosition({ top, left })
    }
  }, [isVisible])

  return (
    <div>
      <h3>Tooltip avec useLayoutEffect</h3>
      
      <button
        ref={triggerRef}
        onMouseEnter={() => setIsVisible(true)}
        onMouseLeave={() => setIsVisible(false)}
        style={{ margin: '100px' }}
      >
        Hover me for tooltip
      </button>

      {isVisible && (
        <div
          ref={tooltipRef}
          style={{
            position: 'fixed',
            top: position.top,
            left: position.left,
            background: '#333',
            color: 'white',
            padding: '8px 12px',
            borderRadius: '4px',
            fontSize: '14px',
            zIndex: 1000,
            // Sans useLayoutEffect, ce tooltip "flasherait" 
            // à une position incorrecte puis se repositionnerait
          }}
        >
          Je suis un tooltip bien positionné !
        </div>
      )}
    </div>
  )
}

useId - Identifiants Uniques

Problème des IDs Statiques

// ❌ PROBLÈME - IDs en dur causent des conflits
function BadForm() {
  return (
    <div>
      <label htmlFor="name">Nom:</label>
      <input id="name" type="text" />
      
      <label htmlFor="email">Email:</label>
      <input id="email" type="email" />
    </div>
  )
}

// Si ce composant est utilisé plusieurs fois sur la même page :
function App() {
  return (
    <div>
      <BadForm /> {/* IDs : name, email */}
      <BadForm /> {/* IDs : name, email ← CONFLIT ! */}
    </div>
  )
}

Solution avec useId

function GoodForm() {
  const nameId = useId()
  const emailId = useId()
  const descriptionId = useId()

  return (
    <div>
      <h4>Formulaire avec IDs uniques</h4>
      
      <div>
        <label htmlFor={nameId}>Nom:</label>
        <input id={nameId} type="text" />
      </div>
      
      <div>
        <label htmlFor={emailId}>Email:</label>
        <input id={emailId} type="email" />
      </div>
      
      <div>
        <label htmlFor={descriptionId}>Description:</label>
        <textarea id={descriptionId} rows={3} />
      </div>
    </div>
  )
}

// Maintenant on peut utiliser le composant plusieurs fois !
function App() {
  return (
    <div>
      <GoodForm /> {/* IDs : :r1:, :r2:, :r3: */}
      <GoodForm /> {/* IDs : :r4:, :r5:, :r6: */}
      <GoodForm /> {/* IDs : :r7:, :r8:, :r9: */}
    </div>
  )
}

Patterns Avancés avec useId

function FormFieldWithId({ label, type = 'text', ...props }) {
  const id = useId()
  
  return (
    <div style={{ marginBottom: '15px' }}>
      <label htmlFor={id} style={{ display: 'block', marginBottom: '5px' }}>
        {label}
      </label>
      <input id={id} type={type} {...props} />
    </div>
  )
}

function RadioGroupWithId({ name, options, value, onChange }) {
  const groupId = useId()
  
  return (
    <fieldset>
      <legend>Choisir une option</legend>
      {options.map((option, index) => {
        const optionId = `${groupId}-${index}`
        
        return (
          <div key={option.value} style={{ marginBottom: '8px' }}>
            <input
              type="radio"
              id={optionId}
              name={name}
              value={option.value}
              checked={value === option.value}
              onChange={onChange}
            />
            <label htmlFor={optionId} style={{ marginLeft: '8px' }}>
              {option.label}
            </label>
          </div>
        )
      })}
    </fieldset>
  )
}

function ComplexForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    category: '',
    newsletter: false
  })

  const handleInputChange = (field, value) => {
    setFormData(prev => ({ ...prev, [field]: value }))
  }

  const categoryOptions = [
    { value: 'dev', label: 'Développeur' },
    { value: 'design', label: 'Designer' },
    { value: 'pm', label: 'Product Manager' }
  ]

  return (
    <form>
      <h3>Formulaire Avancé</h3>
      
      <FormFieldWithId
        label="Nom complet"
        value={formData.name}
        onChange={e => handleInputChange('name', e.target.value)}
        placeholder="Votre nom..."
      />
      
      <FormFieldWithId
        label="Email"
        type="email"
        value={formData.email}
        onChange={e => handleInputChange('email', e.target.value)}
        placeholder="votre@email.com"
      />
      
      <RadioGroupWithId
        name="category"
        options={categoryOptions}
        value={formData.category}
        onChange={e => handleInputChange('category', e.target.value)}
      />
      
      <CheckboxWithId
        label="S'abonner à la newsletter"
        checked={formData.newsletter}
        onChange={e => handleInputChange('newsletter', e.target.checked)}
      />
      
      <button type="submit">Envoyer</button>
      
      <pre style={{ marginTop: '20px', background: '#f8f9fa', padding: '10px' }}>
        {JSON.stringify(formData, null, 2)}
      </pre>
    </form>
  )
}

function CheckboxWithId({ label, checked, onChange }) {
  const id = useId()
  
  return (
    <div style={{ marginBottom: '15px' }}>
      <input
        type="checkbox"
        id={id}
        checked={checked}
        onChange={onChange}
      />
      <label htmlFor={id} style={{ marginLeft: '8px' }}>
        {label}
      </label>
    </div>
  )
}

useTransition et useDeferredValue - Performance

Priorités de Mise à Jour

import { useState, useTransition, useDeferredValue, useMemo } from 'react'

function PerformanceDemo() {
  const [input, setInput] = useState('')
  const [isPending, startTransition] = useTransition()
  
  // Version différée de l'input pour les calculs lourds
  const deferredInput = useDeferredValue(input)
  
  // Simulation d'une recherche très coûteuse
  const searchResults = useMemo(() => {
    if (!deferredInput) return []
    
    console.log('🔍 Recherche coûteuse pour:', deferredInput)
    
    // Simulation de 10000 résultats avec calculs
    const results = []
    for (let i = 0; i < 10000; i++) {
      if (i.toString().includes(deferredInput)) {
        results.push({
          id: i,
          title: `Résultat ${i} pour "${deferredInput}"`,
          score: Math.random() * 100
        })
      }
    }
    
    return results.slice(0, 100) // Limiter à 100 résultats
  }, [deferredInput])

  const handleInputChange = (value) => {
    // Mise à jour urgente de l'input (responsive)
    setInput(value)
    
    // Démarrer une transition pour les mises à jour moins prioritaires
    startTransition(() => {
      // Cette partie sera différée si l'app est occupée
      console.log('🔄 Transition démarrée pour la recherche')
    })
  }

  return (
    <div>
      <h3>Performance avec Transitions</h3>
      
      <div>
        <input
          value={input}
          onChange={e => handleInputChange(e.target.value)}
          placeholder="Rechercher (taper des nombres)..."
          style={{ 
            padding: '10px', 
            width: '300px',
            fontSize: '16px'
          }}
        />
        {isPending && <span style={{ marginLeft: '10px' }}>⏳ Recherche...</span>}
      </div>

      <div style={{ marginTop: '20px' }}>
        <p>
          Input actuel: "{input}" | 
          Recherche pour: "{deferredInput}" | 
          Résultats: {searchResults.length}
        </p>
        
        {deferredInput !== input && (
          <p style={{ color: '#007bff' }}>
            🔄 Recherche en cours pour "{input}"...
          </p>
        )}
      </div>

      <div style={{ 
        maxHeight: '300px', 
        overflow: 'auto',
        border: '1px solid #ddd',
        marginTop: '10px'
      }}>
        {searchResults.map(result => (
          <div key={result.id} style={{ 
            padding: '8px', 
            borderBottom: '1px solid #eee' 
          }}>
            <strong>{result.title}</strong>
            <span style={{ 
              float: 'right', 
              color: '#666',
              fontSize: '14px'
            }}>
              Score: {result.score.toFixed(2)}
            </span>
          </div>
        ))}
      </div>
    </div>
  )
}

Cas Pratique : Liste Filtrée

function LargeFilterableList() {
  const [filter, setFilter] = useState('')
  const [sortBy, setSortBy] = useState('name')
  const [isPending, startTransition] = useTransition()
  
  const deferredFilter = useDeferredValue(filter)
  const deferredSortBy = useDeferredValue(sortBy)

  // Dataset large simulé
  const allItems = useMemo(() => {
    return Array.from({ length: 50000 }, (_, i) => ({
      id: i,
      name: `Item ${i}`,
      category: ['A', 'B', 'C'][i % 3],
      price: Math.random() * 100,
      rating: Math.random() * 5
    }))
  }, [])

  // Filtrage et tri différés (non bloquants)
  const processedItems = useMemo(() => {
    console.log('🔍 Processing items with filter:', deferredFilter)
    
    let filtered = allItems
    
    if (deferredFilter) {
      filtered = allItems.filter(item =>
        item.name.toLowerCase().includes(deferredFilter.toLowerCase()) ||
        item.category.toLowerCase().includes(deferredFilter.toLowerCase())
      )
    }
    
    // Tri
    filtered.sort((a, b) => {
      switch (deferredSortBy) {
        case 'name': return a.name.localeCompare(b.name)
        case 'price': return b.price - a.price // Décroissant
        case 'rating': return b.rating - a.rating // Décroissant
        default: return 0
      }
    })
    
    return filtered.slice(0, 1000) // Limiter l'affichage
  }, [allItems, deferredFilter, deferredSortBy])

  const handleFilterChange = (value) => {
    setFilter(value)
    
    startTransition(() => {
      // Cette mise à jour est différée
      console.log('🔄 Filter transition started')
    })
  }

  const handleSortChange = (value) => {
    startTransition(() => {
      setSortBy(value)
    })
  }

  return (
    <div>
      <h3>Liste Filtrable Performance ({allItems.length} items)</h3>
      
      <div style={{ marginBottom: '20px' }}>
        <input
          value={filter}
          onChange={e => handleFilterChange(e.target.value)}
          placeholder="Filtrer par nom ou catégorie..."
          style={{ 
            padding: '8px', 
            marginRight: '10px',
            width: '250px'
          }}
        />
        
        <select
          value={sortBy}
          onChange={e => handleSortChange(e.target.value)}
          style={{ padding: '8px' }}
        >
          <option value="name">Trier par nom</option>
          <option value="price">Trier par prix</option>
          <option value="rating">Trier par note</option>
        </select>
        
        {isPending && <span style={{ marginLeft: '10px' }}>⏳ Traitement...</span>}
      </div>

      <div style={{ marginBottom: '10px' }}>
        <p>
          Filtre: "{filter}" | 
          Traitement: "{deferredFilter}" | 
          Résultats: {processedItems.length}
        </p>
      </div>

      <div style={{ 
        height: '400px', 
        overflow: 'auto',
        border: '1px solid #ddd'
      }}>
        {processedItems.map(item => (
          <div key={item.id} style={{
            padding: '12px',
            borderBottom: '1px solid #eee',
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center'
          }}>
            <div>
              <strong>{item.name}</strong>
              <span style={{ 
                marginLeft: '10px', 
                color: '#666',
                fontSize: '14px'
              }}>
                Catégorie: {item.category}
              </span>
            </div>
            <div style={{ textAlign: 'right' }}>
              <div>{item.price.toFixed(2)}€</div>
              <div>⭐ {item.rating.toFixed(1)}</div>
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

useImperativeHandle - API Personnalisées

Forward Ref avec API Custom

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

// Composant avec API personnalisée exposée
const AdvancedInput = forwardRef((props, ref) => {
  const inputRef = useRef()
  const [history, setHistory] = useState([])
  const [currentValue, setCurrentValue] = useState('')

  // Exposer une API personnalisée au parent
  useImperativeHandle(ref, () => ({
    // Méthodes de focus
    focus: () => inputRef.current?.focus(),
    blur: () => inputRef.current?.blur(),
    
    // Méthodes de valeur
    getValue: () => currentValue,
    setValue: (value) => {
      setCurrentValue(value)
      addToHistory(value)
    },
    clear: () => {
      setCurrentValue('')
      inputRef.current?.focus()
    },
    
    // Méthodes de sélection
    selectAll: () => inputRef.current?.select(),
    getSelection: () => ({
      start: inputRef.current?.selectionStart || 0,
      end: inputRef.current?.selectionEnd || 0
    }),
    setSelection: (start, end) => {
      inputRef.current?.setSelectionRange(start, end)
    },
    
    // Historique
    getHistory: () => history,
    clearHistory: () => setHistory([]),
    undo: () => {
      if (history.length > 1) {
        const newHistory = history.slice(0, -1)
        const previousValue = newHistory[newHistory.length - 1] || ''
        setHistory(newHistory)
        setCurrentValue(previousValue)
        return previousValue
      }
      return currentValue
    },
    
    // Validation
    isValid: () => {
      if (props.required && !currentValue.trim()) return false
      if (props.minLength && currentValue.length < props.minLength) return false
      if (props.pattern && !new RegExp(props.pattern).test(currentValue)) return false
      return true
    },
    
    // Animation
    shake: () => {
      if (inputRef.current) {
        inputRef.current.style.animation = 'shake 0.5s'
        setTimeout(() => {
          if (inputRef.current) {
            inputRef.current.style.animation = ''
          }
        }, 500)
      }
    }
  }), [currentValue, history, props.required, props.minLength, props.pattern])

  const addToHistory = (value) => {
    setHistory(prev => {
      const newHistory = [...prev, value]
      return newHistory.slice(-10) // Garder seulement les 10 derniers
    })
  }

  const handleChange = (e) => {
    const newValue = e.target.value
    setCurrentValue(newValue)
    addToHistory(newValue)
    props.onChange?.(e)
  }

  return (
    <div>
      <input
        ref={inputRef}
        {...props}
        value={currentValue}
        onChange={handleChange}
        style={{
          padding: '10px',
          border: '2px solid #ddd',
          borderRadius: '4px',
          fontSize: '16px',
          ...props.style
        }}
      />
      
      <style jsx>{`
        @keyframes shake {
          0%, 100% { transform: translateX(0); }
          10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
          20%, 40%, 60%, 80% { transform: translateX(5px); }
        }
      `}</style>
    </div>
  )
})

AdvancedInput.displayName = 'AdvancedInput'

// Utilisation du composant avec API personnalisée
function ImperativeHandleDemo() {
  const inputRef1 = useRef()
  const inputRef2 = useRef()
  const inputRef3 = useRef()

  const handleValidate = () => {
    const inputs = [inputRef1, inputRef2, inputRef3]
    let allValid = true

    inputs.forEach(inputRef => {
      if (inputRef.current && !inputRef.current.isValid()) {
        inputRef.current.shake()
        allValid = false
      }
    })

    if (allValid) {
      alert('Tous les champs sont valides !')
    } else {
      alert('Certains champs ne sont pas valides')
    }
  }

  const handleGetValues = () => {
    const values = {
      name: inputRef1.current?.getValue() || '',
      email: inputRef2.current?.getValue() || '',
      phone: inputRef3.current?.getValue() || ''
    }
    
    console.log('Valeurs actuelles:', values)
    alert(JSON.stringify(values, null, 2))
  }

  const handleClearAll = () => {
    ;[inputRef1, inputRef2, inputRef3].forEach(ref => {
      ref.current?.clear()
    })
  }

  const handleFillExample = () => {
    inputRef1.current?.setValue('Andy Cinquin')
    inputRef2.current?.setValue('andy@example.com')
    inputRef3.current?.setValue('0123456789')
  }

  return (
    <div>
      <h3>useImperativeHandle Demo</h3>
      
      <div style={{ display: 'flex', flexDirection: 'column', gap: '20px', maxWidth: '400px' }}>
        <div>
          <label style={{ display: 'block', marginBottom: '5px' }}>
            Nom (requis, min 3 caractères)
          </label>
          <AdvancedInput
            ref={inputRef1}
            placeholder="Votre nom..."
            required
            minLength={3}
          />
        </div>

        <div>
          <label style={{ display: 'block', marginBottom: '5px' }}>
            Email (requis, format email)
          </label>
          <AdvancedInput
            ref={inputRef2}
            placeholder="votre@email.com"
            required
            pattern="[^@]+@[^@]+\.[^@]+"
          />
        </div>

        <div>
          <label style={{ display: 'block', marginBottom: '5px' }}>
            Téléphone (10 chiffres)
          </label>
          <AdvancedInput
            ref={inputRef3}
            placeholder="0123456789"
            pattern="[0-9]{10}"
          />
        </div>
      </div>

      <div style={{ marginTop: '20px', display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
        <button onClick={() => inputRef1.current?.focus()}>
          Focus Nom
        </button>
        <button onClick={() => inputRef2.current?.selectAll()}>
          Sélectionner Email
        </button>
        <button onClick={() => inputRef3.current?.undo()}>
          Undo Téléphone
        </button>
        <button onClick={handleValidate}>
          Valider Formulaire
        </button>
        <button onClick={handleGetValues}>
          Voir Valeurs
        </button>
        <button onClick={handleClearAll}>
          Tout Effacer
        </button>
        <button onClick={handleFillExample}>
          Remplir Exemple
        </button>
      </div>

      <div style={{ marginTop: '20px' }}>
        <button onClick={() => {
          const history = inputRef1.current?.getHistory() || []
          console.log('Historique nom:', history)
          alert('Historique: ' + history.join(' → '))
        }}>
          Voir Historique Nom
        </button>
      </div>
    </div>
  )
}

useDebugValue - Debug Custom Hooks

Hook de Debug

import { useDebugValue, useState, useEffect } from 'react'

// Hook custom avec debug
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine)

  // Afficher des infos de debug dans React DevTools
  useDebugValue(isOnline ? 'En ligne' : 'Hors ligne')

  useEffect(() => {
    const handleOnline = () => setIsOnline(true)
    const handleOffline = () => setIsOnline(false)

    window.addEventListener('online', handleOnline)
    window.addEventListener('offline', handleOffline)

    return () => {
      window.removeEventListener('online', handleOnline)
      window.removeEventListener('offline', handleOffline)
    }
  }, [])

  return isOnline
}

// Hook custom avec debug complexe
function useApi(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  // Debug value avec objet et formatage conditionnel
  useDebugValue(
    { url, loading, hasData: !!data, error: !!error },
    ({ url, loading, hasData, error }) => {
      if (loading) return `🔄 Loading: ${url}`
      if (error) return `❌ Error: ${url}`
      if (hasData) return `✅ Success: ${url}`
      return `⏳ Idle: ${url}`
    }
  )

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true)
        setError(null)
        const response = await fetch(url)
        if (!response.ok) throw new Error(`HTTP ${response.status}`)
        const result = await response.json()
        setData(result)
      } catch (err) {
        setError(err)
      } finally {
        setLoading(false)
      }
    }

    fetchData()
  }, [url])

  return { data, loading, error }
}

// Hook custom pour formulaire avec debug détaillé
function useForm(initialValues, validation) {
  const [values, setValues] = useState(initialValues)
  const [errors, setErrors] = useState({})
  const [touched, setTouched] = useState({})

  // Debug info complète
  useDebugValue(
    {
      fieldCount: Object.keys(values).length,
      errorCount: Object.keys(errors).length,
      touchedCount: Object.keys(touched).length,
      isValid: Object.keys(errors).length === 0,
      values,
      errors
    },
    (info) => {
      const status = info.isValid ? '✅' : '❌'
      return `${status} Form: ${info.fieldCount} fields, ${info.errorCount} errors, ${info.touchedCount} touched`
    }
  )

  const setValue = (field, value) => {
    setValues(prev => ({ ...prev, [field]: value }))
    
    // Validation en temps réel
    if (validation && validation[field]) {
      const error = validation[field](value)
      setErrors(prev => ({
        ...prev,
        [field]: error
      }))
    }
  }

  const setTouched = (field) => {
    setTouched(prev => ({ ...prev, [field]: true }))
  }

  const reset = () => {
    setValues(initialValues)
    setErrors({})
    setTouched({})
  }

  return {
    values,
    errors,
    touched,
    setValue,
    setTouched,
    reset,
    isValid: Object.keys(errors).length === 0
  }
}

// Utilisation avec debug
function DebugDemo() {
  const isOnline = useOnlineStatus()
  const { data: users, loading, error } = useApi('https://jsonplaceholder.typicode.com/users?_limit=3')
  
  const form = useForm(
    { name: '', email: '' },
    {
      name: (value) => value.length < 3 ? 'Nom trop court' : null,
      email: (value) => !/\S+@\S+\.\S+/.test(value) ? 'Email invalide' : null
    }
  )

  return (
    <div>
      <h3>Debug Demo - Ouvre React DevTools ! 🛠️</h3>
      
      <div style={{ marginBottom: '20px' }}>
        <p>Statut connexion: {isOnline ? '🟢 En ligne' : '🔴 Hors ligne'}</p>
        <p>API: {loading ? 'Chargement...' : error ? 'Erreur' : `${users?.length || 0} users`}</p>
      </div>

      <form>
        <div>
          <label>
            Nom:
            <input
              value={form.values.name}
              onChange={e => form.setValue('name', e.target.value)}
              onBlur={() => form.setTouched('name')}
            />
          </label>
          {form.errors.name && form.touched.name && (
            <span style={{ color: 'red' }}>{form.errors.name}</span>
          )}
        </div>

        <div>
          <label>
            Email:
            <input
              value={form.values.email}
              onChange={e => form.setValue('email', e.target.value)}
              onBlur={() => form.setTouched('email')}
            />
          </label>
          {form.errors.email && form.touched.email && (
            <span style={{ color: 'red' }}>{form.errors.email}</span>
          )}
        </div>

        <button type="button" onClick={form.reset}>
          Reset
        </button>
      </form>

      <div style={{ marginTop: '20px', background: '#f8f9fa', padding: '10px' }}>
        <h4>Ouvre React DevTools pour voir les infos de debug !</h4>
        <p>Dans l'onglet Components, cherche les hooks personnalisés</p>
        <p>Form valide: {form.isValid ? '✅' : '❌'}</p>
      </div>
    </div>
  )
}

Ressources Pour Aller Plus Loin