Más allá de useEffect: Dominando el Ciclo de Vida en React Moderno

Más allá de useEffect: Dominando el Ciclo de Vida en React Moderno
Photo by Mohammad Rahmani / Unsplash

Introducción

El manejo de efectos secundarios ha sido uno de los aspectos más desafiantes en el desarrollo de aplicaciones React. Durante años, los componentes de clase dominaron el ecosistema, ofreciendo métodos como componentDidMount, componentDidUpdate y componentWillUnmount para gestionar lo que comúnmente llamábamos "ciclo de vida". Sin embargo, este enfoque presentaba problemas fundamentales: código duplicado, lógica dispersa y dificultad para reutilizar comportamientos.

Con la llegada de React Hooks en 2019, el equipo de React no solo nos dio una nueva API, sino también un cambio de paradigma completo. Este cambio no fue simplemente sintáctico; representó una nueva forma de pensar sobre los efectos en nuestras aplicaciones.

🔍 Detalle técnico
React Hooks fue introducido en la versión 16.8.0, transformando fundamentalmente cómo escribimos componentes React y separando la lógica de estado y efectos de la jerarquía de componentes.

La evolución del manejo de efectos en React

La historia de los efectos en React refleja la maduración de la biblioteca y nuestro entendimiento de las aplicaciones frontend:

  1. Componentes de clase (2013-2019): Efectos vinculados a eventos específicos del ciclo de vida del componente.
  2. Primeros Hooks (2019): useEffect unifica los métodos de ciclo de vida en una sola API.
  3. React moderno (2020+): Enfoque en sincronización declarativa en lugar de eventos imperativos.

Los desafíos del modelo de "ciclo de vida" tradicional

El modelo tradicional de ciclo de vida presentaba varios problemas inherentes:

  • Fragmentación de lógica relacionada: El código para iniciar una suscripción (componentDidMount) debía separarse del código para limpiarla (componentWillUnmount).
  • Duplicación de código: La misma lógica a menudo debía repetirse en componentDidMount y componentDidUpdate.
  • Complejidad creciente: A medida que los componentes evolucionaban, los métodos de ciclo de vida se convertían en una mezcla confusa de lógicas no relacionadas.
// ❌ Lógica relacionada fragmentada en diferentes métodos
class UserProfile extends React.Component {
  componentDidMount() {
    document.title = `Perfil de ${this.props.username}`;
    this.fetchUserData();
    window.addEventListener('resize', this.handleResize);
  }

  componentDidUpdate(prevProps) {
    if (prevProps.username !== this.props.username) {
      document.title = `Perfil de ${this.props.username}`;
      this.fetchUserData();
    }
  }

  componentWillUnmount() {
    window.removeEventListener('resize', this.handleResize);
  }

  // Resto del componente...
}

Nuevo paradigma: pensar en sincronización en lugar de ciclo de vida

El cambio fundamental con useEffect es conceptual: pasamos de pensar en "¿cuándo debe ejecutarse este código?" a "¿con qué debe sincronizarse este efecto?".

Esta distinción puede parecer sutil, pero transforma profundamente cómo estructuramos nuestros componentes. En lugar de responder a eventos específicos del ciclo de vida, declaramos efectos que se mantienen sincronizados con ciertas piezas de datos.

// ✅ Lógica agrupada por preocupación, no por tiempo de ejecución
function UserProfile({ username }) {
  useEffect(() => {
    document.title = `Perfil de ${username}`;
    // Este efecto se sincroniza con cambios en username
  }, [username]);

  useEffect(() => {
    const fetchData = async () => {
      // Lógica para obtener datos
    };
    
    fetchData();
    // Este efecto también se sincroniza con username
  }, [username]);

  useEffect(() => {
    const handleResize = () => {
      // Lógica de resize
    };
    
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
    // Este efecto no depende de props o state
  }, []);

  // Resto del componente...
}

Fundamentos de useEffect

Para dominar useEffect, debemos abandonar nuestros viejos modelos mentales y adoptar uno nuevo basado en sincronización y reactividad.

El modelo mental correcto para useEffect

useEffect no es simplemente una combinación de los métodos de ciclo de vida. Es una herramienta declarativa para sincronizar un componente con sistemas externos (APIs, DOM, suscripciones).

Un modelo mental efectivo es pensar en useEffect como un mecanismo que responde a la pregunta: "¿Qué efectos secundarios necesitan mantenerse sincronizados con el estado actual de mi componente?".

useEffect(() => {
  // Este código se ejecuta después de cada renderizado
  // donde las dependencias hayan cambiado
  
  return () => {
    // Esta función se ejecuta antes del próximo efecto
    // o cuando el componente se desmonta
  };
}, [dependencia1, dependencia2]);

Sincronización vs eventos del ciclo de vida

La diferencia clave entre el enfoque de sincronización y el de eventos de ciclo de vida es la intención:

  • Eventos de ciclo de vida: "Ejecuta este código cuando el componente se monte, actualice o desmonte."
  • Sincronización: "Mantén este efecto sincronizado con estas piezas de datos."

Este cambio de perspectiva simplifica nuestro código y lo hace más predecible. En lugar de preocuparnos por el momento exacto en que se ejecuta un efecto, nos centramos en con qué datos debe mantenerse sincronizado.

💡 Pro tip
Cuando defines un efecto, pregúntate: "¿Qué estado o props necesita observar este efecto para mantenerse sincronizado?" Esos son exactamente los valores que deben ir en el array de dependencias.

La importancia del array de dependencias

El array de dependencias es el mecanismo que utiliza React para determinar cuándo volver a ejecutar un efecto. Es crucial entenderlo correctamente:

  • Array vacío []: El efecto se ejecuta solo una vez después del montaje inicial.
  • Sin array: El efecto se ejecuta después de cada renderizado.
  • Con valores [a, b, c]: El efecto se ejecuta cuando cualquiera de estos valores cambia.
// Ejecuta el efecto solo una vez (equivalente a componentDidMount)
useEffect(() => {
  console.log('Componente montado');
}, []);

// Ejecuta el efecto después de cada renderizado
useEffect(() => {
  console.log('Componente renderizado');
});

// Ejecuta el efecto cuando userId o theme cambian
useEffect(() => {
  console.log('Usuario o tema cambiado');
}, [userId, theme]);

El error más común es omitir dependencias para controlar cuándo se ejecuta un efecto. Esto va contra el modelo de sincronización y puede provocar bugs sutiles.

Problemas comunes con useEffect

Incluso con un buen modelo mental, useEffect puede presentar desafíos. Veamos algunos de los problemas más comunes y cómo solucionarlos.

Dependencias circulares

Un patrón problemático es cuando un efecto actualiza un estado que a su vez provoca que el efecto se ejecute nuevamente, creando un bucle infinito.

// ❌ Bucle infinito
function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    setCount(count + 1); // Este cambio de estado provoca otro renderizado
  }, [count]); // El nuevo renderizado cambia count, ejecutando el efecto nuevamente
  
  return <div>{count}</div>;
}

Soluciones:

  1. Usar el actualizador funcional para evitar la dependencia en el estado actual:
// ✅ Actualización funcional sin dependencia circular
useEffect(() => {
  setCount(c => c + 1); // Usa el valor actual sin necesidad de tenerlo como dependencia
}, []); // Solo se ejecuta una vez
  1. Evaluar si realmente necesitas un efecto. A menudo, los efectos que solo actualizan estado basado en otro estado pueden reemplazarse con cálculo directo durante el renderizado.

Efectos que se ejecutan demasiado o muy poco

Otro problema común es cuando los efectos se ejecutan con una frecuencia incorrecta:

Ejecutándose demasiado:

// ❌ Se ejecuta en cada renderizado porque el objeto se crea nuevamente
useEffect(() => {
  fetchData(options);
}, [options]); // options es un objeto creado en cada renderizado

// Definición problemática de options
const options = { 
  userId: user.id,
  timeout: 1000
};

Solución: Memoiza objetos y funciones con useMemo y useCallback:

// ✅ El objeto options solo se recreará cuando userId cambie
const options = useMemo(() => ({
  userId: user.id,
  timeout: 1000
}), [user.id]);

Ejecutándose muy poco:

// ❌ Dependencia faltante
function UserProfile({ userId }) {
  const [userData, setUserData] = useState(null);
  
  useEffect(() => {
    async function fetchUser() {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      setUserData(data);
    }
    
    fetchUser();
  }, []); // userId falta en las dependencias
  
  // ...
}

Solución: Incluye todas las dependencias necesarias:

// ✅ Dependencias completas
useEffect(() => {
  async function fetchUser() {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();
    setUserData(data);
  }
  
  fetchUser();
}, [userId]); // userId incluido correctamente
💡 Pro tip
Usa ESLint con la regla react-hooks/exhaustive-deps para detectar automáticamente dependencias faltantes o innecesarias en tus efectos.

Race conditions en peticiones asíncronas

Las condiciones de carrera ocurren cuando múltiples peticiones asíncronas se resuelven en un orden diferente al esperado, causando que el estado final del componente sea incorrecto.

// ❌ Vulnerable a condiciones de carrera
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    fetchResults(query).then(data => {
      setResults(data); // Puede establecer resultados obsoletos
    });
  }, [query]);
  
  return <ResultsList results={results} />;
}

Si el usuario cambia query rápidamente, una consulta más reciente podría resolverse antes que una anterior, mostrando resultados incorrectos.

Solución: Implementar un flag de cancelación:

// ✅ Protección contra condiciones de carrera
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    let isMounted = true;
    
    fetchResults(query).then(data => {
      if (isMounted) {
        setResults(data);
      }
    });
    
    return () => {
      isMounted = false;
    };
  }, [query]);
  
  return <ResultsList results={results} />;
}

Una solución más moderna usando la API AbortController:

useEffect(() => {
  const controller = new AbortController();
  
  const fetchData = async () => {
    try {
      const response = await fetch(`/api/search?q=${query}`, {
        signal: controller.signal
      });
      const data = await response.json();
      setResults(data);
    } catch (error) {
      if (error.name !== 'AbortError') {
        console.error('Error fetching results:', error);
      }
    }
  };
  
  fetchData();
  
  return () => controller.abort();
}, [query]);

useLayoutEffect: Cuándo y por qué

useLayoutEffect es el gemelo menos conocido de useEffect, con una diferencia crucial en su tiempo de ejecución.

Diferencias clave con useEffect

La principal diferencia entre ambos hooks está en cuándo se ejecutan:

  • useEffect: Se ejecuta asincrónicamente después de que el renderizado se completa y el navegador ha pintado los cambios.
  • useLayoutEffect: Se ejecuta sincrónicamente inmediatamente después de que React ha calculado los cambios en el DOM pero antes de que el navegador los pinte.

Esta diferencia de tiempo es sutil pero crítica en ciertos escenarios:

// Secuencia de ejecución
function Component() {
  console.log(1, 'Renderizado inicia');
  
  useLayoutEffect(() => {
    console.log(3, 'useLayoutEffect ejecutado');
  });
  
  useEffect(() => {
    console.log(4, 'useEffect ejecutado');
  });
  
  console.log(2, 'Renderizado completa');
  
  return <div>Ejemplo</div>;
}

// Consola mostrará: 1, 2, 3, 4

Casos de uso específicos: mediciones de DOM, animaciones

useLayoutEffect brilla en escenarios donde necesitas leer del DOM y realizar cambios que afectan visualmente al usuario:

  1. Mediciones y cálculos basados en el DOM:
function Tooltip({ children, position }) {
  const tooltipRef = useRef();
  const [tooltipHeight, setTooltipHeight] = useState(0);
  
  // Usamos useLayoutEffect para medir antes de que el navegador pinte
  useLayoutEffect(() => {
    if (tooltipRef.current) {
      const height = tooltipRef.current.getBoundingClientRect().height;
      setTooltipHeight(height);
    }
  }, [position]);
  
  // La posición ya considera la altura real antes de pintarse
  const tooltipStyle = {
    position: 'absolute',
    top: `calc(100% + 10px - ${tooltipHeight}px)`,
    // ...
  };
  
  return (
    <div style={{ position: 'relative' }}>
      {children}
      <div ref={tooltipRef} style={tooltipStyle}>
        Contenido del tooltip
      </div>
    </div>
  );
}
  1. Prevención de parpadeos y efectos visuales jarring:
function AutoResizingTextarea({ value }) {
  const textareaRef = useRef();
  
  // Ajusta la altura antes del pintado para evitar parpadeos
  useLayoutEffect(() => {
    const textarea = textareaRef.current;
    if (textarea) {
      // Guarda la posición de scroll actual
      const { scrollTop } = document.documentElement;
      
      // Resetea la altura y deja que crezca
      textarea.style.height = 'auto';
      textarea.style.height = `${textarea.scrollHeight}px`;
      
      // Restaura la posición de scroll
      window.scrollTo(0, scrollTop);
    }
  }, [value]);
  
  return (
    <textarea
      ref={textareaRef}
      value={value}
      style={{ overflow: 'hidden' }}
    />
  );
}

Consideraciones de rendimiento

Aunque useLayoutEffect es poderoso, viene con advertencias importantes:

  • Es bloqueante: Retrasa el pintado visual hasta que se complete, lo que puede afectar la percepción de rendimiento.
  • Puede causar jank: Operaciones prolongadas en useLayoutEffect pueden hacer que la UI se sienta lenta o entrecortada.
⚠️ Advertencia
Usa useLayoutEffect solo cuando sea estrictamente necesario. Para la mayoría de los efectos, useEffect es la opción correcta ya que no bloquea el pintado visual.

Una buena regla: usa useLayoutEffect cuando necesites hacer cambios en el DOM que afectarían visualmente al usuario si se hicieran después del pintado.

Efectos con limpieza efectiva

La función de limpieza (cleanup) de los efectos es crucial para prevenir memory leaks y comportamientos inesperados cuando un componente se actualiza o desmonta.

Patrones para cleanup functions

La función de limpieza se ejecuta:

  1. Antes de que el efecto se ejecute nuevamente debido a cambios en dependencias
  2. Cuando el componente se desmonta

El patrón básico de limpieza:

useEffect(() => {
  // Configuración del efecto
  
  return () => {
    // Código de limpieza
  };
}, [dependencias]);

Patrón de cancelación general:

useEffect(() => {
  let isCancelled = false;
  
  async function fetchData() {
    try {
      const result = await api.getData();
      
      if (!isCancelled) {
        setData(result);
      }
    } catch (error) {
      if (!isCancelled) {
        setError(error);
      }
    }
  }
  
  fetchData();
  
  return () => {
    isCancelled = true;
  };
}, []);

Evitar memory leaks en suscripciones

Los memory leaks comunes ocurren cuando el componente se desmonta pero las suscripciones persisten:

// ✅ Limpieza apropiada de suscripciones
function EventListener() {
  useEffect(() => {
    function handleResize() {
      // Lógica de manejo de resize
    }
    
    window.addEventListener('resize', handleResize);
    
    // Limpieza crucial para evitar memory leaks
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
  
  return <div>Componente con listener</div>;
}

Patrón para suscripciones a bibliotecas externas:

function SubscriptionComponent() {
  useEffect(() => {
    const subscription = externalAPI.subscribe(handleDataUpdate);
    
    return () => {
      subscription.unsubscribe();
    };
  }, []);
  
  // ...
}

Cancelación de requests

Para APIs basadas en Promises, podemos usar la API AbortController:

function DataFetcher({ resourceId }) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    const controller = new AbortController();
    const signal = controller.signal;
    
    fetch(`/api/data/${resourceId}`, { signal })
      .then(response => response.json())
      .then(data => {
        if (!signal.aborted) {
          setData(data);
        }
      })
      .catch(error => {
        if (error.name !== 'AbortError') {
          console.error('Error fetching data:', error);
        }
      });
    
    return () => controller.abort();
  }, [resourceId]);
  
  return <div>{/* Renderiza data */}</div>;
}

Para bibliotecas como Axios:

import axios from 'axios';

function AxiosDataFetcher({ userId }) {
  useEffect(() => {
    const source = axios.CancelToken.source();
    
    axios.get(`/api/users/${userId}`, {
      cancelToken: source.token
    })
      .then(response => {
        // Maneja respuesta
      })
      .catch(error => {
        if (!axios.isCancel(error)) {
          // Maneja errores que no son de cancelación
        }
      });
    
    return () => source.cancel('Componente desmontado');
  }, [userId]);
  
  // ...
}
💡 Pro tip
Crea un hook personalizado para manejar la cancelación de peticiones de manera consistente en toda tu aplicación.
function useAsyncEffect(callback, dependencies) {
  useEffect(() => {
    const controller = new AbortController();
    const signal = controller.signal;
    
    callback(signal);
    
    return () => controller.abort();
  }, dependencies);
}

// Uso:
function Component() {
  useAsyncEffect(async (signal) => {
    const response = await fetch('/api/data', { signal });
    const data = await response.json();
    // ...
  }, []);
}

useEffect vs useMemo vs useCallback

Estos tres hooks sirven para propósitos distintos pero complementarios, y a menudo hay confusión sobre cuándo usar cada uno.

Clarificando la confusión común

  • useEffect: Para sincronización con sistemas externos y efectos secundarios.
  • useMemo: Para memorizar valores calculados costosos.
  • useCallback: Para memorizar definiciones de funciones.

La diferencia principal está en su propósito:

// Sincroniza con un sistema externo (DOM)
useEffect(() => {
  document.title = `${count} clicks`;
}, [count]);

// Memoriza un valor calculado costoso
const expensiveValue = useMemo(() => {
  return computeExpensiveValue(a, b);
}, [a, b]);

// Memoriza una definición de función
const handleClick = useCallback(() => {
  setCount(c => c + step);
}, [step]);

Cuándo usar cada uno

useEffect: Cuando necesitas:

  • Interactuar con APIs del navegador (DOM, localStorage, etc.)
  • Suscribirte a fuentes de datos externas
  • Realizar peticiones de red
  • Establecer y limpiar temporizadores
  • Loggear cambios en componentes
// Efectos secundarios típicos
useEffect(() => {
  const subscription = dataSource.subscribe();
  return () => subscription.unsubscribe();
}, [dataSource]);

useMemo: Cuando necesitas:

  • Evitar recálculos costosos en cada renderizado
  • Estabilizar referencias de objetos para prevenir renderizados innecesarios
  • Derivar estado complejo a partir de props o state
// Cálculo costoso memorizado
const sortedItems = useMemo(() => {
  console.log('Sorting items...');
  return [...items].sort((a, b) => a.localeCompare(b));
}, [items]);

useCallback: Cuando necesitas:

  • Pasar funciones como props a componentes memorizados
  • Evitar recreaciones innecesarias de event handlers
  • Estabilizar funciones usadas en efectos
// Handler estabilizado para componentes memorizados
const handleItemSelect = useCallback((id) => {
  setSelectedId(id);
}, []);

// Componente que evita renderizados innecesarios
const MemoizedChild = React.memo(ChildComponent);

return <MemoizedChild onSelect={handleItemSelect} />;

Combinándolos efectivamente

Estos hooks a menudo trabajan juntos para optimizar componentes:

function SearchResults({ query, filters }) {
  // Memoriza la configuración de filtrado para evitar recreaciones
  const filterConfig = useMemo(() => ({
    ...filters,
    timestamp: Date.now()
  }), [filters]);
  
  // Memoriza la función para búsqueda
  const fetchResults = useCallback(async (searchQuery, config) => {
    const response = await api.search(searchQuery, config);
    return response.data;
  }, []);
  
  // Usa el callback memorizado en el efecto
  useEffect(() => {
    let isMounted = true;
    
    fetchResults(query, filterConfig)
      .then(data => {
        if (isMounted) setResults(data);
      });
    
    return () => { isMounted = false; };
  }, [query, filterConfig, fetchResults]);
  
  // ...
}
🔍 Detalle técnico
useMemo y useCallback son optimizaciones, no garantías. React puede decidir "olvidar" valores memorizados en ciertas circunstancias, como liberación de memoria.

Patrones avanzados con useEffect

A medida que las aplicaciones crecen en complejidad, necesitamos patrones más sofisticados para gestionar efectos.

Debouncing y throttling de efectos

Para operaciones que podrían dispararse frecuentemente (como búsquedas mientras el usuario escribe), necesitamos limitar la frecuencia de ejecución:

Debouncing: Esperar hasta que haya una pausa en los eventos.

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  
  // Efecto para realizar búsqueda debounced
  useEffect(() => {
    const timeoutId = setTimeout(() => {
      if (query.length > 2) {
        performSearch(query).then(setResults);
      }
    }, 300); // Espera 300ms de inactividad
    
    return () => clearTimeout(timeoutId);
  }, [query]);
  
  return (
    <>
      <input 
        value={query} 
        onChange={e => setQuery(e.target.value)} 
        placeholder="Buscar..." 
      />
      <Results items={results} />
    </>
  );
}

Throttling: Limitar la frecuencia máxima de ejecución.

function ScrollAnalytics() {
  useEffect(() => {
    let lastExecuted = 0;
    let timeoutId = null;
    
    function handleScroll() {
      const now = Date.now();
      
      if (now - lastExecuted > 1000) {
        // Ejecutar inmediatamente si ha pasado al menos 1s
        trackScrollPosition(window.scrollY);
        lastExecuted = now;
      } else if (!timeoutId) {
        // Programar para 1s después de la última ejecución
        timeoutId = setTimeout(() => {
          trackScrollPosition(window.scrollY);
          lastExecuted = Date.now();
          timeoutId = null;
        }, 1000 - (now - lastExecuted));
      }
    }
    
    window.addEventListener('scroll', handleScroll);
    return () => {
      window.removeEventListener('scroll', handleScroll);
      if (timeoutId) clearTimeout(timeoutId);
    };
  }, []);
  
  // ...
}
💡 Pro tip
Considera crear hooks personalizados como useDebounce y useThrottle para reutilizar esta lógica en tu aplicación.
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => clearTimeout(handler);
  }, [value, delay]);
  
  return debouncedValue;
}

// Uso
function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedTerm = useDebounce(searchTerm, 500);
  
  useEffect(() => {
    if (debouncedTerm) {
      performSearch(debouncedTerm);
    }
  }, [debouncedTerm]);
  
  // ...
}

Polling y sincronización periódica

Para mantener datos actualizados periódicamente:

function LiveDataComponent({ resourceId }) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    // Carga inicial
    fetchData(resourceId).then(setData);
    
    // Configuración del polling
    const intervalId = setInterval(() => {
      fetchData(resourceId).then(setData);
    }, 10000); // Actualiza cada 10 segundos
    
    return () => clearInterval(intervalId);
  }, [resourceId]);
  
  // ...
}

Para polling inteligente con backoff exponencial:

function AdaptivePollingComponent({ resourceId }) {
  const [data, setData] = useState(null);
  const [interval, setInterval] = useState(1000);
  const [isIdle, setIsIdle] = useState(false);
  
  // Efecto para detección de inactividad
  useEffect(() => {
    const activityHandler = () => setIsIdle(false);
    
    window.addEventListener('mousemove', activityHandler);
    window.addEventListener('keypress', activityHandler);
    
    const idleTimerId = setTimeout(() => setIsIdle(true), 60000);
    
    return () => {
      window.removeEventListener('mousemove', activityHandler);
      window.removeEventListener('keypress', activityHandler);
      clearTimeout(idleTimerId);
    };
  }, []);
  
  // Efecto para polling adaptativo
  useEffect(() => {
    // Si está inactivo, aumentar el intervalo
    if (isIdle && interval < 30000) {
      setInterval(currentInterval => Math.min(currentInterval * 2, 30000));
    } else if (!isIdle && interval > 1000) {
      // Si está activo, reducir el intervalo
      setInterval(1000);
    }
  }, [isIdle, interval]);
  
  // Efecto para el polling con el intervalo actual
  useEffect(() => {
    const timerId = setInterval(() => {
      fetchData(resourceId).then(setData);
    }, interval);
    
    return () => clearInterval(timerId);
  }, [resourceId, interval]);
  
  // ...
}