Intermediaire 11 min de lecture · 2 217 mots

React Hooks avancés : useEffect, useContext, et custom hooks

Estimated reading time: 11 minutes

Introduction

Les hooks React sont la fondation des applications modernes. Après avoir maîtrisé useState, il est temps d’explorer useEffect pour les side effects, useContext pour partager des données, et créer vos propres hooks personnalisés.

1. useEffect : Gérer les side effects

Concepts fondamentaux

import { useEffect, useState } from 'react';

// useEffect s'exécute après chaque render
useEffect(() => {
  // Code du side effect
  console.log('Composant rendu');
});

// Avec tableau de dépendances vide : s'exécute une seule fois (mount)
useEffect(() => {
  console.log('Composant monté');
}, []);

// Avec dépendances : s'exécute quand les dépendances changent
useEffect(() => {
  console.log('Count a changé:', count);
}, [count]);

// Avec cleanup : s'exécute au démontage
useEffect(() => {
  const timer = setInterval(() => {}, 1000);

  return () => {
    clearInterval(timer); // Cleanup
  };
}, []);

Cycle de vie avec useEffect

const LifecycleDemo = () => {
  const [count, setCount] = useState(0);

  // 1. Mount (équivalent componentDidMount)
  useEffect(() => {
    console.log('✅ Composant monté');

    // 4. Unmount (équivalent componentWillUnmount)
    return () => {
      console.log('❌ Composant démonté');
    };
  }, []);

  // 2. Update (équivalent componentDidUpdate)
  useEffect(() => {
    console.log('🔄 Count mis à jour:', count);
  }, [count]);

  // 3. Every render
  useEffect(() => {
    console.log('🎨 Render complet');
  });

  return (
    

Count: {count}

); };

Fetch de données avec useEffect

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

const UserProfile = ({ userId }: { userId: number }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Reset l'état lors du changement d'userId
    setLoading(true);
    setError(null);

    // AbortController pour annuler le fetch si le composant démonte
    const controller = new AbortController();

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

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

        const data = await response.json();
        setUser(data);
      } catch (err) {
        // Ignorer les erreurs d'abort
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchUser();

    // Cleanup : annule le fetch si le composant démonte
    return () => controller.abort();
  }, [userId]); // Re-fetch quand userId change

  if (loading) return 
Chargement...
; if (error) return
Erreur: {error}
; if (!user) return
Utilisateur introuvable
; return (
## {user.name}

{user.email}

); };

Event listeners avec useEffect

const WindowSize = () => {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    const handleResize = () => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };

    // Ajouter l'event listener
    window.addEventListener('resize', handleResize);

    // Cleanup : retirer l'event listener
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // Pas de dépendances : s'exécute une seule fois

  return (
    

Largeur: {windowSize.width}px

Hauteur: {windowSize.height}px

); };

Timers et intervals

const Timer = () => {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

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

    const interval = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    // Cleanup : nettoyer l'interval
    return () => clearInterval(interval);
  }, [isRunning]); // Re-créer l'interval quand isRunning change

  return (
    
## Timer: {seconds}s
); };

Erreurs courantes et solutions

// ❌ ERREUR : Dépendances manquantes
const BadExample = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(count + 1); // count est "stale" après le premier render
    }, 1000);

    return () => clearInterval(interval);
  }, []); // ⚠️ count devrait être dans les dépendances

  return 
{count}
; }; // ✅ SOLUTION 1 : Utiliser la fonction de mise à jour const GoodExample1 = () => { const [count, setCount] = useState(0); useEffect(() => { const interval = setInterval(() => { setCount(c => c + 1); // Utilise la valeur actuelle }, 1000); return () => clearInterval(interval); }, []); // ✅ Pas besoin de count dans les dépendances return
{count}
; }; // ✅ SOLUTION 2 : Ajouter count aux dépendances const GoodExample2 = () => { const [count, setCount] = useState(0); useEffect(() => { const interval = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(interval); }, [count]); // ✅ count dans les dépendances return
{count}
; }; // ❌ ERREUR : Fetch infini const BadFetch = () => { const [data, setData] = useState([]); useEffect(() => { fetch('/api/data') .then(res => res.json()) .then(setData); }); // ⚠️ Pas de dépendances = s'exécute à chaque render return
{data.length}
; }; // ✅ SOLUTION : Dépendances vides const GoodFetch = () => { const [data, setData] = useState([]); useEffect(() => { fetch('/api/data') .then(res => res.json()) .then(setData); }, []); // ✅ S'exécute une seule fois return
{data.length}
; };

2. useContext : State global sans prop drilling

Créer un Context

// theme-context.tsx
import { createContext, useContext, useState, ReactNode } from 'react';

type Theme = 'light' | 'dark';

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

// Créer le Context avec une valeur par défaut
const ThemeContext = createContext(undefined);

// Provider component
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
  const [theme, setTheme] = useState('light');

  const toggleTheme = () => {
    setTheme(t => t === 'light' ? 'dark' : 'light');
  };

  const value = {
    theme,
    toggleTheme
  };

  return (
    
      {children}
    
  );
};

// Hook personnalisé pour utiliser le Context
export const useTheme = () => {
  const context = useContext(ThemeContext);

  if (context === undefined) {
    throw new Error('useTheme doit être utilisé dans un ThemeProvider');
  }

  return context;
};

Utiliser le Context

// App.tsx
import { ThemeProvider } from './theme-context';

const App = () => {
  return (
    
      
); }; // Header.tsx const Header = () => { const { theme, toggleTheme } = useTheme(); return (
header-${theme}
}> # Mon App ); }; // Footer.tsx (profondément imbriqué, mais accède au Context facilement) const Footer = () => { const { theme } = useTheme(); return (
footer-${theme}}>

© 2025 - Thème actuel: {theme}

); };

Context complexe : Authentification

// auth-context.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

interface AuthContextType {
  user: User | null;
  loading: boolean;
  login: (email: string, password: string) => Promise;
  logout: () => Promise;
  isAuthenticated: boolean;
  isAdmin: boolean;
}

const AuthContext = createContext(undefined);

export const AuthProvider = ({ children }: { children: ReactNode }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  // Vérifier si l'utilisateur est déjà connecté au chargement
  useEffect(() => {
    const checkAuth = async () => {
      try {
        const response = await fetch('/api/auth/me');
        if (response.ok) {
          const userData = await response.json();
          setUser(userData);
        }
      } catch (error) {
        console.error('Auth check failed:', error);
      } finally {
        setLoading(false);
      }
    };

    checkAuth();
  }, []);

  const login = async (email: string, password: string) => {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    });

    if (!response.ok) {
      throw new Error('Login failed');
    }

    const userData = await response.json();
    setUser(userData);
  };

  const logout = async () => {
    await fetch('/api/auth/logout', { method: 'POST' });
    setUser(null);
  };

  const value = {
    user,
    loading,
    login,
    logout,
    isAuthenticated: user !== null,
    isAdmin: user?.role === 'admin'
  };

  return (
    
      {children}
    
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);

  if (context === undefined) {
    throw new Error('useAuth doit être utilisé dans un AuthProvider');
  }

  return context;
};

Utiliser le Context d’authentification

// LoginPage.tsx
const LoginPage = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const { login } = useAuth();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');

    try {
      await login(email, password);
    } catch (err) {
      setError('Email ou mot de passe incorrect');
    }
  };

  return (
    
setEmail(e.target.value)} placeholder="Email" /> setPassword(e.target.value)} placeholder="Mot de passe" /> {error &&

{error}

}
); }; // ProtectedRoute.tsx const ProtectedRoute = ({ children }: { children: ReactNode }) => { const { isAuthenticated, loading } = useAuth(); if (loading) { return
Chargement...
; } if (!isAuthenticated) { return ; } return <>{children}>; }; // AdminPanel.tsx const AdminPanel = () => { const { isAdmin, user } = useAuth(); if (!isAdmin) { return
Accès refusé
; } return (
# Panel Admin

Bienvenue {user?.name}

); };

Combiner plusieurs Contexts

// App.tsx
const App = () => {
  return (
    
      
        
          
            
          
        
      
    
  );
};

// Ou avec un helper
const AppProviders = ({ children }: { children: ReactNode }) => {
  return (
    
      
        
          {children}
        
      
    
  );
};

const App = () => {
  return (
    
      
        
      
    
  );
};

3. Custom Hooks : Créer vos propres hooks

Hook simple : useLocalStorage

function useLocalStorage(key: string, initialValue: T) {
  // State pour stocker la valeur
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  // Fonction pour modifier la valeur
  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue] as const;
}

// Utilisation
const Settings = () => {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
  const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);

  return (
    
setFontSize(parseInt(e.target.value))} />
); };

Hook pour fetch de données : useFetch

interface UseFetchResult {
  data: T | null;
  loading: boolean;
  error: string | null;
  refetch: () => void;
}

function useFetch(url: string): UseFetchResult {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url);

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

      const json = await response.json();
      setData(json);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Une erreur est survenue');
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  return { data, loading, error, refetch: fetchData };
}

// Utilisation
interface User {
  id: number;
  name: string;
}

const UserList = () => {
  const { data, loading, error, refetch } = useFetch('/api/users');

  if (loading) return 
Chargement...
; if (error) return
Erreur: {error}
; return (
    {data?.map(user => (
  • {user.name}
  • ))}
); };

Hook pour debounce : useDebounce

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

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

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

// Utilisation : Recherche avec debounce
const SearchBar = () => {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  const { data, loading } = useFetch(
    /api/search?q=${debouncedSearchTerm}
  );

  return (
    
setSearchTerm(e.target.value)} placeholder="Rechercher..." /> {loading && Recherche...} {data && }
); };

Hook pour media queries : useMediaQuery

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => {
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    const mediaQuery = window.matchMedia(query);

    const handleChange = (e: MediaQueryListEvent) => {
      setMatches(e.matches);
    };

    mediaQuery.addEventListener('change', handleChange);

    return () => {
      mediaQuery.removeEventListener('change', handleChange);
    };
  }, [query]);

  return matches;
}

// Utilisation
const ResponsiveLayout = () => {
  const isMobile = useMediaQuery('(max-width: 768px)');
  const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
  const isDesktop = useMediaQuery('(min-width: 1025px)');

  return (
    
{isMobile && } {isTablet && } {isDesktop && }
); };

Hook pour intersection observer : useInView

function useInView(options?: IntersectionObserverInit) {
  const [ref, setRef] = useState(null);
  const [isInView, setIsInView] = useState(false);

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

    const observer = new IntersectionObserver(([entry]) => {
      setIsInView(entry.isIntersecting);
    }, options);

    observer.observe(ref);

    return () => {
      observer.disconnect();
    };
  }, [ref, options]);

  return [setRef, isInView] as const;
}

// Utilisation : Lazy loading d'images
const LazyImage = ({ src, alt }: { src: string; alt: string }) => {
  const [ref, isInView] = useInView({ threshold: 0.1 });

  return (
    
{isInView ? ( {alt} ) : (
Chargement...
)}
); };

Hook complexe : useAsync

type AsyncState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function useAsync(asyncFunction: () => Promise, immediate = true) {
  const [state, setState] = useState>({ status: 'idle' });

  const execute = useCallback(async () => {
    setState({ status: 'loading' });

    try {
      const data = await asyncFunction();
      setState({ status: 'success', data });
      return data;
    } catch (error) {
      setState({
        status: 'error',
        error: error instanceof Error ? error : new Error('Unknown error')
      });
      throw error;
    }
  }, [asyncFunction]);

  useEffect(() => {
    if (immediate) {
      execute();
    }
  }, [execute, immediate]);

  return { ...state, execute };
}

// Utilisation
const UserProfile = ({ userId }: { userId: number }) => {
  const fetchUser = useCallback(
    () => fetch(/api/users/${userId}).then(res => res.json()),
    [userId]
  );

  const { status, data, error, execute } = useAsync(fetchUser);

  if (status === 'loading') return 
Chargement...
; if (status === 'error') return
Erreur: {error.message}
; if (status === 'success') { return (
## {data.name}
); } return null; };

4. Tests des hooks

// useFetch.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from './useFetch';

describe('useFetch', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  test('retourne les données avec succès', async () => {
    const mockData = { id: 1, name: 'Test' };
    (global.fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      json: async () => mockData
    });

    const { result } = renderHook(() => useFetch('/api/test'));

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBeNull();
  });

  test('gère les erreurs', async () => {
    (global.fetch as jest.Mock).mockRejectedValueOnce(
      new Error('Network error')
    );

    const { result } = renderHook(() => useFetch('/api/test'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.error).toBe('Network error');
    expect(result.current.data).toBeNull();
  });
});

// useDebounce.test.ts
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';

jest.useFakeTimers();

describe('useDebounce', () => {
  test('debounce la valeur', () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'initial', delay: 500 } }
    );

    expect(result.current).toBe('initial');

    // Changer la valeur
    rerender({ value: 'changed', delay: 500 });

    // La valeur ne change pas immédiatement
    expect(result.current).toBe('initial');

    // Avancer le temps
    act(() => {
      jest.advanceTimersByTime(500);
    });

    // Maintenant la valeur est mise à jour
    expect(result.current).toBe('changed');
  });
});

5. Performance et optimisation

Éviter les re-renders inutiles avec useCallback

// ❌ Mauvaise pratique
const Parent = () => {
  const [count, setCount] = useState(0);

  // Cette fonction est recréée à chaque render
  const handleClick = () => {
    console.log('Clicked');
  };

  return ;
};

// ✅ Bonne pratique
const Parent = () => {
  const [count, setCount] = useState(0);

  // Cette fonction est mémorisée
  const handleClick = useCallback(() => {
    console.log('Clicked');
  }, []); // Dépendances vides = fonction stable

  return ;
};

Mémoriser les valeurs de Context

// ❌ Mauvaise pratique : re-render à chaque fois
const ThemeProvider = ({ children }: { children: ReactNode }) => {
  const [theme, setTheme] = useState('light');

  return (
    
      {children}
    
  );
};

// ✅ Bonne pratique : mémoriser la valeur
const ThemeProvider = ({ children }: { children: ReactNode }) => {
  const [theme, setTheme] = useState('light');

  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    
      {children}
    
  );
};

Conclusion

Vous maîtrisez maintenant les hooks avancés de React :

  • useEffect pour les side effects et le cycle de vie
  • useContext pour partager des données globalement
  • Custom hooks pour réutiliser la logique
  • Points clés

  • useEffect avec cleanup pour éviter les memory leaks
  • useContext pour éviter le prop drilling
  • Custom hooks pour encapsuler la logique réutilisable
  • Toujours typer avec TypeScript
  • Tester vos hooks avec React Testing Library
  • Ressources

  • React Hooks Documentation
  • useHooks.com
  • React Hooks Testing Library

Une remarque, un retour ?

Cet article est vivant — corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.