Pular para o conteúdo
TypeScript

Tipar useEffect no React com TypeScript

O useEffect parece simples, mas esconde armadilhas de tipagem que pegam até devs experientes. Aprenda a tipar cleanup functions, lidar com async e usar AbortController do jeito certo.

Por que isso é importante

Tipar useEffect no React com TypeScript. O useEffect parece simples, mas esconde armadilhas de tipagem que pegam até devs experientes. Aprenda a tipar cleanup functions, lidar com async e usar AbortController do jeito certo.

O que o TypeScript realmente checa no useEffect

Diferente do useState ou useRef, o useEffect nao recebe um tipo generico. O TypeScript valida o useEffect de tres formas: o callback precisa retornar void ou uma cleanup function, o dependency array precisa conter valores validos, e funcoes async nao podem ser passadas diretamente como callback.

Na pratica, isso significa que o TypeScript te protege de padroes perigosos que funcionam em JavaScript puro mas geram problemas serios. A assinatura do useEffect e fixa: ele espera uma funcao que retorna void ou uma funcao de limpeza. Qualquer coisa diferente disso gera erro de compilacao.

// Assinatura do useEffect no TypeScript
useEffect(
  effect: () => void | (() => void),
  deps?: ReadonlyArray<unknown>
): void;

// O callback retorna:
// - void (sem cleanup)
// - () => void (com cleanup function)

Passo a passo: tipando useEffect corretamente

  1. Defina o callback sem async direto - useEffect nao aceita Promise como retorno
  2. Se precisar de cleanup, retorne uma funcao anonima no final do callback
  3. Para chamadas async, crie uma funcao interna e chame ela dentro do callback
  4. Use AbortController para cancelar fetch quando o componente desmontar
  5. Tipar as dependencias no array garante que o TypeScript valide mudancas
  6. Nunca ignore warnings de dependencia - eles existem por um motivo

Vamos ver cada um desses pontos com codigo real. A ideia e que voce copie, adapte e use no seu projeto sem medo.

Exemplos praticos de useEffect tipado

useEffect basico com cleanup

O caso mais comum: voce adiciona um event listener e precisa remover quando o componente sai da tela. O TypeScript garante que a cleanup function nao retorne nada alem de void.

import { useEffect } from 'react';

function useWindowResize(callback: (width: number) => void) {
  useEffect(() => {
    const handleResize = () => {
      callback(window.innerWidth);
    };

    window.addEventListener('resize', handleResize);

    // Cleanup: remove o listener ao desmontar
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, [callback]);
}

useEffect com async e AbortController

Da pra usar async dentro do useEffect sim, so nao pode passar a funcao async diretamente. O truque e criar uma funcao interna. E com AbortController voce cancela o fetch se o componente desmontar antes da resposta chegar.

import { useEffect, useState } from 'react';

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

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

  useEffect(() => {
    const controller = new AbortController();

    const fetchUser = async () => {
      try {
        setLoading(true);
        setError(null);

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

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

        const data: User = await response.json();
        setUser(data);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchUser();

    return () => {
      controller.abort();
    };
  }, [userId]);

  if (loading) return <p>Carregando...</p>;
  if (error) return <p>Erro: {error}</p>;
  return <p>{user?.name}</p>;
}

useEffect com interval tipado

Outro padrao recorrente: setInterval dentro do useEffect. O TypeScript reclama se voce nao tipar o retorno do setInterval direito. Olha como fica limpo:

import { useEffect, useState } from 'react';

function usePolling<T>(
  fetcher: (signal: AbortSignal) => Promise<T>,
  intervalMs: number
) {
  const [data, setData] = useState<T | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    const poll = async () => {
      try {
        const result = await fetcher(controller.signal);
        setData(result);
      } catch {
        // Ignora erros de abort
      }
    };

    poll(); // Primeira chamada imediata
    const id: ReturnType<typeof setInterval> = setInterval(poll, intervalMs);

    return () => {
      clearInterval(id);
      controller.abort();
    };
  }, [fetcher, intervalMs]);

  return data;
}

Tipando dependencias com objetos complexos

Quando a dependencia e um objeto, o useEffect re-executa toda render porque a referencia muda. O TypeScript nao te avisa disso automaticamente, mas voce pode usar useMemo pra estabilizar:

import { useEffect, useMemo } from 'react';

interface Filters {
  category: string;
  sortBy: 'name' | 'date' | 'price';
  page: number;
}

function ProductList({ category, sortBy, page }: Filters) {
  // Estabiliza a referencia do objeto
  const filters = useMemo<Filters>(
    () => ({ category, sortBy, page }),
    [category, sortBy, page]
  );

  useEffect(() => {
    const controller = new AbortController();

    const loadProducts = async () => {
      const params = new URLSearchParams({
        category: filters.category,
        sort: filters.sortBy,
        page: String(filters.page),
      });

      await fetch(`/api/products?${params}`, {
        signal: controller.signal,
      });
    };

    loadProducts();

    return () => controller.abort();
  }, [filters]);

  return null;
}

Erros comuns e como evitar

Cuidado com esses erros

Passar funcao async diretamente no useEffect gera erro de tipo porque async retorna Promise, e o useEffect espera void. Outro erro classico: esquecer do AbortController e causar memory leaks com fetch pendente apos o componente desmontar. O TypeScript pega o primeiro, mas o segundo voce precisa cuidar na mao.

// ERRADO: async direto no useEffect
useEffect(async () => {
  const data = await fetch('/api/users');
  // TypeScript Error: Type 'Promise<void>' is not
  // assignable to type 'void | (() => void)'
}, []);

// CERTO: funcao async interna
useEffect(() => {
  const load = async () => {
    const data = await fetch('/api/users');
  };
  load();
}, []);

// ERRADO: retornar valor no cleanup
useEffect(() => {
  return 'cleanup'; // Erro: string nao e void | (() => void)
}, []);

// CERTO: retornar funcao no cleanup
useEffect(() => {
  return () => {
    console.log('componente desmontou');
  };
}, []);

Mais um detalhe que pega muita gente: colocar um objeto literal no dependency array. O TypeScript aceita, mas o useEffect vai re-executar toda render porque {} !== {} por referencia. Use valores primitivos ou estabilize com useMemo.

Checklist: useEffect tipado sem erros

Checklist Final

  • Callback do useEffect retorna void ou cleanup function
  • Funcoes async sao criadas dentro do callback, nunca como callback direto
  • AbortController usado em todo fetch dentro de useEffect
  • Dependency array contem apenas valores primitivos ou referencias estaveis
  • Cleanup function limpa listeners, intervals e subscriptions
  • ReturnType usado para tipar IDs de interval
  • Nenhum warning de exhaustive-deps ignorado sem justificativa
  • Objetos no dependency array estabilizados com useMemo

Proximo passo: domine TypeScript na pratica

Projeto completo com TypeScript

Saber tipar useEffect e so o comeco. No CrazyStack voce constroi um projeto real do zero usando TypeScript, React e Node.js com todas as boas praticas que o mercado exige. Da tipagem de hooks ate a arquitetura do backend, tudo num unico projeto.