React Hooks Avancés : Patterns et Optimisations 2024

Maîtrisez les hooks React avancés avec des patterns éprouvés. useCallback, useMemo, custom hooks et optimisations performance par un expert React.

Image de l'article : React Hooks Avancés : Patterns et Optimisations 2024

React Hooks Avancés : Patterns et Optimisations 2024

Les hooks React ont transformé notre façon de développer. Après des années d’expérience React, voici mes patterns avancés et optimisations essentielles pour créer des applications performantes et maintenables.

1. Hooks de Performance : useCallback et useMemo

useCallback : Optimisation des Fonctions

import React, { useState, useCallback, memo } from 'react';

// Composant enfant optimisé avec memo
const UserCard = memo(({ user, onUpdate, onDelete }) => {
  console.log('UserCard render:', user.name);
  
  return (
    <div className="user-card">
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      <button onClick={() => onUpdate(user.id)}>
        Modifier
      </button>
      <button onClick={() => onDelete(user.id)}>
        Supprimer
      </button>
    </div>
  );
});

// Composant parent avec useCallback
const UserList = () => {
  const [users, setUsers] = useState([]);
  const [filter, setFilter] = useState('');

  // ❌ Mauvais : nouvelle fonction à chaque render
  const handleUpdate = (userId) => {
    setUsers(prev => prev.map(user => 
      user.id === userId ? { ...user, lastModified: Date.now() } : user
    ));
  };

  // ✅ Bon : fonction mémorisée
  const handleUpdateMemo = useCallback((userId) => {
    setUsers(prev => prev.map(user => 
      user.id === userId ? { ...user, lastModified: Date.now() } : user
    ));
  }, []); // Dépendances vides car on utilise la forme fonctionnelle de setState

  const handleDelete = useCallback((userId) => {
    setUsers(prev => prev.filter(user => user.id !== userId));
  }, []);

  // Fonction avec dépendances
  const handleSearch = useCallback((searchTerm) => {
    setFilter(searchTerm);
    // Logique complexe de recherche
    console.log('Searching for:', searchTerm);
  }, []); // filter n'est pas nécessaire ici car on utilise setFilter directement

  return (
    <div>
      <input 
        type="text" 
        placeholder="Rechercher..."
        onChange={(e) => handleSearch(e.target.value)}
      />
      
      {users.map(user => (
        <UserCard
          key={user.id}
          user={user}
          onUpdate={handleUpdateMemo}
          onDelete={handleDelete}
        />
      ))}
    </div>
  );
};

useMemo : Optimisation des Calculs

import React, { useState, useMemo, useCallback } from 'react';

const ExpensiveCalculations = ({ data, filter, sortBy }) => {
  // ❌ Mauvais : calcul coûteux à chaque render
  const expensiveResult = data
    .filter(item => item.name.includes(filter))
    .sort((a, b) => a[sortBy] - b[sortBy])
    .reduce((acc, item) => acc + item.value, 0);

  // ✅ Bon : calcul mémorisé
  const memoizedResult = useMemo(() => {
    console.log('Calcul coûteux exécuté');
    return data
      .filter(item => item.name.includes(filter))
      .sort((a, b) => a[sortBy] - b[sortBy])
      .reduce((acc, item) => acc + item.value, 0);
  }, [data, filter, sortBy]); // Recalculé uniquement si les dépendances changent

  // Objet complexe mémorisé
  const processedData = useMemo(() => {
    return {
      filtered: data.filter(item => item.name.includes(filter)),
      sorted: data.sort((a, b) => a[sortBy] - b[sortBy]),
      grouped: data.reduce((acc, item) => {
        const category = item.category;
        if (!acc[category]) acc[category] = [];
        acc[category].push(item);
        return acc;
      }, {}),
      total: memoizedResult
    };
  }, [data, filter, sortBy, memoizedResult]);

  return (
    <div>
      <h2>Résultat: {memoizedResult}</h2>
      <pre>{JSON.stringify(processedData, null, 2)}</pre>
    </div>
  );
};

2. Custom Hooks Avancés

useApi - Hook pour les Appels API

import { useState, useEffect, useCallback, useRef } from 'react';

const useApi = (url, options = {}) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const abortControllerRef = useRef(null);

  const fetchData = useCallback(async (customUrl = url, customOptions = {}) => {
    try {
      // Annuler la requête précédente si elle existe
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }

      abortControllerRef.current = new AbortController();
      
      setLoading(true);
      setError(null);

      const response = await fetch(customUrl, {
        signal: abortControllerRef.current.signal,
        ...options,
        ...customOptions,
        headers: {
          'Content-Type': 'application/json',
          ...options.headers,
          ...customOptions.headers,
        },
      });

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

      const result = await response.json();
      setData(result);
      return result;
    } catch (err) {
      if (err.name !== 'AbortError') {
        setError(err.message);
        console.error('API Error:', err);
      }
    } finally {
      setLoading(false);
    }
  }, [url, options]);

  const refetch = useCallback(() => {
    return fetchData();
  }, [fetchData]);

  const mutate = useCallback((newData) => {
    setData(newData);
  }, []);

  useEffect(() => {
    if (url) {
      fetchData();
    }

    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [fetchData]);

  return {
    data,
    loading,
    error,
    refetch,
    mutate,
    execute: fetchData, // Pour les appels manuels
  };
};

// Utilisation du hook useApi
const UserProfile = ({ userId }) => {
  const { data: user, loading, error, mutate } = useApi(
    userId ? `/api/users/${userId}` : null,
    { method: 'GET' }
  );

  const { execute: updateUser, loading: updating } = useApi();

  const handleUpdate = async (updates) => {
    try {
      const updatedUser = await updateUser(`/api/users/${userId}`, {
        method: 'PUT',
        body: JSON.stringify(updates),
      });
      
      // Mettre à jour le cache local
      mutate(updatedUser);
    } catch (err) {
      console.error('Erreur lors de la mise à jour:', err);
    }
  };

  if (loading) return <div>Chargement...</div>;
  if (error) return <div>Erreur: {error}</div>;
  if (!user) return <div>Utilisateur non trouvé</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <button 
        onClick={() => handleUpdate({ lastLogin: new Date().toISOString() })}
        disabled={updating}
      >
        {updating ? 'Mise à jour...' : 'Marquer comme connecté'}
      </button>
    </div>
  );
};

useForm - Gestion de Formulaires Avancée

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

const useForm = (initialValues = {}, validationSchema = {}) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  // Validation d'un champ
  const validateField = useCallback((name, value) => {
    const rules = validationSchema[name];
    if (!rules) return null;

    for (const rule of rules) {
      if (!rule.validator(value, values)) {
        return rule.message;
      }
    }
    return null;
  }, [validationSchema, values]);

  // Validation de tout le formulaire
  const validateForm = useCallback(() => {
    const newErrors = {};
    let isValid = true;

    Object.keys(validationSchema).forEach(name => {
      const error = validateField(name, values[name]);
      if (error) {
        newErrors[name] = error;
        isValid = false;
      }
    });

    setErrors(newErrors);
    return isValid;
  }, [validationSchema, values, validateField]);

  // État de validation global
  const isValid = useMemo(() => {
    return Object.keys(errors).length === 0 && 
           Object.keys(touched).length > 0;
  }, [errors, touched]);

  const isDirty = useMemo(() => {
    return Object.keys(values).some(key => 
      values[key] !== initialValues[key]
    );
  }, [values, initialValues]);

  // Gestionnaires
  const handleChange = useCallback((name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
    
    // Validation en temps réel si le champ a été touché
    if (touched[name]) {
      const error = validateField(name, value);
      setErrors(prev => ({
        ...prev,
        [name]: error || undefined
      }));
    }
  }, [touched, validateField]);

  const handleBlur = useCallback((name) => {
    setTouched(prev => ({ ...prev, [name]: true }));
    
    const error = validateField(name, values[name]);
    setErrors(prev => ({
      ...prev,
      [name]: error || undefined
    }));
  }, [values, validateField]);

  const handleSubmit = useCallback(async (onSubmit) => {
    setIsSubmitting(true);
    
    // Marquer tous les champs comme touchés
    const allTouched = Object.keys(validationSchema).reduce((acc, key) => {
      acc[key] = true;
      return acc;
    }, {});
    setTouched(allTouched);

    const isFormValid = validateForm();
    
    if (isFormValid) {
      try {
        await onSubmit(values);
      } catch (error) {
        console.error('Erreur de soumission:', error);
      }
    }
    
    setIsSubmitting(false);
  }, [values, validationSchema, validateForm]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  }, [initialValues]);

  const setFieldValue = useCallback((name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
  }, []);

  const setFieldError = useCallback((name, error) => {
    setErrors(prev => ({ ...prev, [name]: error }));
  }, []);

  return {
    values,
    errors,
    touched,
    isSubmitting,
    isValid,
    isDirty,
    handleChange,
    handleBlur,
    handleSubmit,
    reset,
    setFieldValue,
    setFieldError,
    validateForm,
  };
};

// Exemple d'utilisation avec validation
const UserRegistrationForm = () => {
  const validationSchema = {
    email: [
      {
        validator: (value) => value && value.length > 0,
        message: 'Email requis'
      },
      {
        validator: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
        message: 'Format d\'email invalide'
      }
    ],
    password: [
      {
        validator: (value) => value && value.length >= 8,
        message: 'Le mot de passe doit contenir au moins 8 caractères'
      },
      {
        validator: (value) => /(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value),
        message: 'Le mot de passe doit contenir au moins une minuscule, une majuscule et un chiffre'
      }
    ],
    confirmPassword: [
      {
        validator: (value, allValues) => value === allValues.password,
        message: 'Les mots de passe ne correspondent pas'
      }
    ]
  };

  const {
    values,
    errors,
    touched,
    isSubmitting,
    isValid,
    isDirty,
    handleChange,
    handleBlur,
    handleSubmit,
    reset
  } = useForm({
    email: '',
    password: '',
    confirmPassword: ''
  }, validationSchema);

  const onSubmit = async (formData) => {
    try {
      const response = await fetch('/api/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData)
      });
      
      if (response.ok) {
        alert('Inscription réussie!');
        reset();
      }
    } catch (error) {
      alert('Erreur lors de l\'inscription');
    }
  };

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      handleSubmit(onSubmit);
    }}>
      <div>
        <input
          type="email"
          placeholder="Email"
          value={values.email}
          onChange={(e) => handleChange('email', e.target.value)}
          onBlur={() => handleBlur('email')}
          className={errors.email && touched.email ? 'error' : ''}
        />
        {errors.email && touched.email && (
          <span className="error-message">{errors.email}</span>
        )}
      </div>

      <div>
        <input
          type="password"
          placeholder="Mot de passe"
          value={values.password}
          onChange={(e) => handleChange('password', e.target.value)}
          onBlur={() => handleBlur('password')}
          className={errors.password && touched.password ? 'error' : ''}
        />
        {errors.password && touched.password && (
          <span className="error-message">{errors.password}</span>
        )}
      </div>

      <div>
        <input
          type="password"
          placeholder="Confirmer le mot de passe"
          value={values.confirmPassword}
          onChange={(e) => handleChange('confirmPassword', e.target.value)}
          onBlur={() => handleBlur('confirmPassword')}
          className={errors.confirmPassword && touched.confirmPassword ? 'error' : ''}
        />
        {errors.confirmPassword && touched.confirmPassword && (
          <span className="error-message">{errors.confirmPassword}</span>
        )}
      </div>

      <button 
        type="submit" 
        disabled={!isValid || isSubmitting}
      >
        {isSubmitting ? 'Inscription...' : 'S\'inscrire'}
      </button>
      
      <button 
        type="button" 
        onClick={reset}
        disabled={!isDirty}
      >
        Réinitialiser
      </button>
    </form>
  );
};

3. useReducer pour la Gestion d’État Complexe

import React, { useReducer, useCallback, useMemo } from 'react';

// Types d'actions
const ACTIONS = {
  FETCH_START: 'FETCH_START',
  FETCH_SUCCESS: 'FETCH_SUCCESS',
  FETCH_ERROR: 'FETCH_ERROR',
  ADD_ITEM: 'ADD_ITEM',
  UPDATE_ITEM: 'UPDATE_ITEM',
  DELETE_ITEM: 'DELETE_ITEM',
  SET_FILTER: 'SET_FILTER',
  SET_SORT: 'SET_SORT',
  TOGGLE_SELECTION: 'TOGGLE_SELECTION',
  SELECT_ALL: 'SELECT_ALL',
  CLEAR_SELECTION: 'CLEAR_SELECTION',
};

// État initial
const initialState = {
  items: [],
  loading: false,
  error: null,
  filter: '',
  sortBy: 'name',
  sortOrder: 'asc',
  selectedIds: new Set(),
};

// Reducer
const dataReducer = (state, action) => {
  switch (action.type) {
    case ACTIONS.FETCH_START:
      return {
        ...state,
        loading: true,
        error: null,
      };

    case ACTIONS.FETCH_SUCCESS:
      return {
        ...state,
        loading: false,
        items: action.payload,
        error: null,
      };

    case ACTIONS.FETCH_ERROR:
      return {
        ...state,
        loading: false,
        error: action.payload,
      };

    case ACTIONS.ADD_ITEM:
      return {
        ...state,
        items: [...state.items, action.payload],
      };

    case ACTIONS.UPDATE_ITEM:
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload.id 
            ? { ...item, ...action.payload.updates }
            : item
        ),
      };

    case ACTIONS.DELETE_ITEM:
      const newSelectedIds = new Set(state.selectedIds);
      newSelectedIds.delete(action.payload);
      
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload),
        selectedIds: newSelectedIds,
      };

    case ACTIONS.SET_FILTER:
      return {
        ...state,
        filter: action.payload,
      };

    case ACTIONS.SET_SORT:
      return {
        ...state,
        sortBy: action.payload.field,
        sortOrder: state.sortBy === action.payload.field && state.sortOrder === 'asc' 
          ? 'desc' 
          : 'asc',
      };

    case ACTIONS.TOGGLE_SELECTION:
      const newSelection = new Set(state.selectedIds);
      if (newSelection.has(action.payload)) {
        newSelection.delete(action.payload);
      } else {
        newSelection.add(action.payload);
      }
      return {
        ...state,
        selectedIds: newSelection,
      };

    case ACTIONS.SELECT_ALL:
      return {
        ...state,
        selectedIds: new Set(action.payload),
      };

    case ACTIONS.CLEAR_SELECTION:
      return {
        ...state,
        selectedIds: new Set(),
      };

    default:
      return state;
  }
};

// Hook personnalisé utilisant useReducer
const useDataManager = (initialItems = []) => {
  const [state, dispatch] = useReducer(dataReducer, {
    ...initialState,
    items: initialItems,
  });

  // Actions
  const actions = useMemo(() => ({
    fetchStart: () => dispatch({ type: ACTIONS.FETCH_START }),
    fetchSuccess: (items) => dispatch({ type: ACTIONS.FETCH_SUCCESS, payload: items }),
    fetchError: (error) => dispatch({ type: ACTIONS.FETCH_ERROR, payload: error }),
    addItem: (item) => dispatch({ type: ACTIONS.ADD_ITEM, payload: item }),
    updateItem: (id, updates) => dispatch({ type: ACTIONS.UPDATE_ITEM, payload: { id, updates } }),
    deleteItem: (id) => dispatch({ type: ACTIONS.DELETE_ITEM, payload: id }),
    setFilter: (filter) => dispatch({ type: ACTIONS.SET_FILTER, payload: filter }),
    setSort: (field) => dispatch({ type: ACTIONS.SET_SORT, payload: { field } }),
    toggleSelection: (id) => dispatch({ type: ACTIONS.TOGGLE_SELECTION, payload: id }),
    selectAll: (ids) => dispatch({ type: ACTIONS.SELECT_ALL, payload: ids }),
    clearSelection: () => dispatch({ type: ACTIONS.CLEAR_SELECTION }),
  }), []);

  // Données calculées
  const computedData = useMemo(() => {
    let filteredItems = state.items;

    // Filtrage
    if (state.filter) {
      filteredItems = filteredItems.filter(item =>
        item.name.toLowerCase().includes(state.filter.toLowerCase()) ||
        item.description.toLowerCase().includes(state.filter.toLowerCase())
      );
    }

    // Tri
    filteredItems.sort((a, b) => {
      const aVal = a[state.sortBy];
      const bVal = b[state.sortBy];
      
      let comparison = 0;
      if (aVal < bVal) comparison = -1;
      else if (aVal > bVal) comparison = 1;
      
      return state.sortOrder === 'asc' ? comparison : -comparison;
    });

    const selectedItems = filteredItems.filter(item => 
      state.selectedIds.has(item.id)
    );

    return {
      filteredItems,
      selectedItems,
      selectedCount: state.selectedIds.size,
      totalCount: state.items.length,
      filteredCount: filteredItems.length,
      allSelected: filteredItems.length > 0 && 
                   filteredItems.every(item => state.selectedIds.has(item.id)),
      someSelected: state.selectedIds.size > 0 && 
                    !filteredItems.every(item => state.selectedIds.has(item.id)),
    };
  }, [state.items, state.filter, state.sortBy, state.sortOrder, state.selectedIds]);

  // Fonctions utilitaires
  const toggleSelectAll = useCallback(() => {
    if (computedData.allSelected) {
      actions.clearSelection();
    } else {
      actions.selectAll(computedData.filteredItems.map(item => item.id));
    }
  }, [computedData.allSelected, computedData.filteredItems, actions]);

  const deleteSelected = useCallback(() => {
    Array.from(state.selectedIds).forEach(id => {
      actions.deleteItem(id);
    });
  }, [state.selectedIds, actions]);

  return {
    state,
    actions,
    computedData,
    toggleSelectAll,
    deleteSelected,
  };
};

// Composant utilisant le hook
const DataTable = () => {
  const {
    state,
    actions,
    computedData,
    toggleSelectAll,
    deleteSelected
  } = useDataManager([
    { id: 1, name: 'Item 1', description: 'Description 1', category: 'A' },
    { id: 2, name: 'Item 2', description: 'Description 2', category: 'B' },
    { id: 3, name: 'Item 3', description: 'Description 3', category: 'A' },
  ]);

  return (
    <div>
      {/* Contrôles */}
      <div className="controls">
        <input
          type="text"
          placeholder="Filtrer..."
          value={state.filter}
          onChange={(e) => actions.setFilter(e.target.value)}
        />
        
        <button
          onClick={() => actions.addItem({
            id: Date.now(),
            name: `Nouvel item ${Date.now()}`,
            description: 'Description',
            category: 'A'
          })}
        >
          Ajouter un item
        </button>
        
        <button
          onClick={deleteSelected}
          disabled={computedData.selectedCount === 0}
        >
          Supprimer la sélection ({computedData.selectedCount})
        </button>
      </div>

      {/* Table */}
      <table>
        <thead>
          <tr>
            <th>
              <input
                type="checkbox"
                checked={computedData.allSelected}
                ref={input => {
                  if (input) input.indeterminate = computedData.someSelected;
                }}
                onChange={toggleSelectAll}
              />
            </th>
            <th onClick={() => actions.setSort('name')}>
              Nom {state.sortBy === 'name' && (state.sortOrder === 'asc' ? '↑' : '↓')}
            </th>
            <th onClick={() => actions.setSort('description')}>
              Description {state.sortBy === 'description' && (state.sortOrder === 'asc' ? '↑' : '↓')}
            </th>
            <th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {computedData.filteredItems.map(item => (
            <tr key={item.id}>
              <td>
                <input
                  type="checkbox"
                  checked={state.selectedIds.has(item.id)}
                  onChange={() => actions.toggleSelection(item.id)}
                />
              </td>
              <td>{item.name}</td>
              <td>{item.description}</td>
              <td>
                <button onClick={() => actions.deleteItem(item.id)}>
                  Supprimer
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      {/* Informations */}
      <div className="info">
        {computedData.filteredCount} / {computedData.totalCount} items
        {computedData.selectedCount > 0 && ` - ${computedData.selectedCount} sélectionnés`}
      </div>
    </div>
  );
};

4. Hooks de Contexte Avancés

import React, { createContext, useContext, useReducer, useCallback } from 'react';

// Context pour le thème
const ThemeContext = createContext();

const themeReducer = (state, action) => {
  switch (action.type) {
    case 'TOGGLE_THEME':
      return { ...state, darkMode: !state.darkMode };
    case 'SET_THEME':
      return { ...state, darkMode: action.payload };
    case 'SET_PRIMARY_COLOR':
      return { ...state, primaryColor: action.payload };
    default:
      return state;
  }
};

export const ThemeProvider = ({ children }) => {
  const [state, dispatch] = useReducer(themeReducer, {
    darkMode: false,
    primaryColor: '#007bff',
  });

  const toggleTheme = useCallback(() => {
    dispatch({ type: 'TOGGLE_THEME' });
  }, []);

  const setTheme = useCallback((isDark) => {
    dispatch({ type: 'SET_THEME', payload: isDark });
  }, []);

  const setPrimaryColor = useCallback((color) => {
    dispatch({ type: 'SET_PRIMARY_COLOR', payload: color });
  }, []);

  const value = {
    ...state,
    toggleTheme,
    setTheme,
    setPrimaryColor,
  };

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
};

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
};

Conclusion

La maîtrise des hooks avancés est essentielle pour développer des applications React performantes et maintenables. Les patterns présentés permettent de créer des composants réutilisables, optimisés et robustes.

Points clés à retenir :

  • Utilisez useCallback et useMemo avec parcimonie
  • Créez des custom hooks pour la logique réutilisable
  • useReducer pour les états complexes
  • Optimisez avec memo et les dépendances des hooks

Besoin d’aide avec React ? Contactez-moi pour du développement ou de la formation React avancé.


Mathieu Mont - Expert React, Vue.js, et développement Frontend moderne