Dominando Anatomia de Hooks Customizados: Composição e Separação de Concerns em Projetos Reais Já leu

O Que São Hooks Customizados e Por Que Importam Hooks customizados são funções JavaScript que reutilizam lógica com estado e efeitos colaterais, encapsulando comportamentos complexos em unidades testáveis e compostas. No React, quando você percebe que está repetindo a mesma lógica de gerenciamento de estado em múltiplos componentes, é hora de extrair essa lógica para um hook customizado. A essência aqui não é apenas evitar código duplicado, mas criar abstrações que tornem seu código mais legível, testável e manutenível. A diferença fundamental entre hooks customizados e componentes é que hooks retornam apenas dados e funções, enquanto componentes retornam JSX. Isso significa que você pode compor múltiplos hooks dentro de um componente ou até combinar hooks customizados dentro de outros hooks customizados. Essa composição é o cerne da arquitetura moderna com React, permitindo que você construa sistemas complexos a partir de pequenas peças bem definidas. Princípios de Composição em Hooks Customizados Composição Horizontal vs. Vertical Composição horizontal refere-se ao uso de

O Que São Hooks Customizados e Por Que Importam

Hooks customizados são funções JavaScript que reutilizam lógica com estado e efeitos colaterais, encapsulando comportamentos complexos em unidades testáveis e compostas. No React, quando você percebe que está repetindo a mesma lógica de gerenciamento de estado em múltiplos componentes, é hora de extrair essa lógica para um hook customizado. A essência aqui não é apenas evitar código duplicado, mas criar abstrações que tornem seu código mais legível, testável e manutenível.

A diferença fundamental entre hooks customizados e componentes é que hooks retornam apenas dados e funções, enquanto componentes retornam JSX. Isso significa que você pode compor múltiplos hooks dentro de um componente ou até combinar hooks customizados dentro de outros hooks customizados. Essa composição é o cerne da arquitetura moderna com React, permitindo que você construa sistemas complexos a partir de pequenas peças bem definidas.

Princípios de Composição em Hooks Customizados

Composição Horizontal vs. Vertical

Composição horizontal refere-se ao uso de múltiplos hooks dentro de um único componente, cada um responsável por um aspecto diferente da lógica. Por exemplo, um hook para gerenciar autenticação, outro para buscar dados, e mais um para rastreamento de erros — todos vivendo lado a lado no mesmo componente. Composição vertical é quando você cria hooks que dependem de outros hooks, formando uma hierarquia de abstrações.

A chave para uma boa composição é garantir que cada hook tenha uma responsabilidade única e bem definida. Violações do Single Responsibility Principle (SRP) resultam em hooks inflexíveis que são difíceis de testar e reutilizar. Veja um exemplo prático:

// ❌ Péssimo: Hook fazendo múltiplas coisas
function useUserAndPosts(userId) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    Promise.all([
      fetch(`/api/users/${userId}`).then(r => r.json()),
      fetch(`/api/posts?userId=${userId}`).then(r => r.json())
    ])
      .then(([userData, postsData]) => {
        setUser(userData);
        setPosts(postsData);
      })
      .catch(err => setError(err))
      .finally(() => setLoading(false));
  }, [userId]);

  return { user, posts, loading, error };
}

// ✅ Excelente: Hooks separados e compostos
function useUser(userId) {
  const [user, setUser] = useState(null);
  const { loading, error, execute } = useAsync();

  useEffect(() => {
    execute(fetch(`/api/users/${userId}`).then(r => r.json()))
      .then(setUser);
  }, [userId, execute]);

  return { user, loading, error };
}

function usePosts(userId) {
  const [posts, setPosts] = useState([]);
  const { loading, error, execute } = useAsync();

  useEffect(() => {
    execute(fetch(`/api/posts?userId=${userId}`).then(r => r.json()))
      .then(setPosts);
  }, [userId, execute]);

  return { posts, loading, error };
}

function useAsync() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const execute = useCallback(async (promise) => {
    setLoading(true);
    setError(null);
    try {
      return await promise;
    } catch (err) {
      setError(err);
      throw err;
    } finally {
      setLoading(false);
    }
  }, []);

  return { loading, error, execute };
}

// Composição em um componente
function UserProfile({ userId }) {
  const { user, loading: userLoading, error: userError } = useUser(userId);
  const { posts, loading: postsLoading, error: postsError } = usePosts(userId);

  if (userLoading || postsLoading) return <div>Carregando...</div>;
  if (userError || postsError) return <div>Erro ao carregar dados</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <ul>
        {posts.map(post => <li key={post.id}>{post.title}</li>)}
      </ul>
    </div>
  );
}

Invertendo o Controle com Callbacks

Um padrão poderoso é permitir que quem usar seu hook customize seu comportamento através de callbacks. Isso inverte a dependência: em vez de o hook ser acoplado a casos de uso específicos, é o componente que injeta seu próprio comportamento.

// Hook genérico para qualquer tipo de requisição
function useFetch(url, onSuccess, onError) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetch(url)
      .then(r => r.json())
      .then(result => {
        setData(result);
        onSuccess?.(result);
      })
      .catch(err => {
        onError?.(err);
      })
      .finally(() => setLoading(false));
  }, [url, onSuccess, onError]);

  return { data, loading };
}

// Reutilização em diferentes contextos
function ProductList() {
  const { data: products, loading } = useFetch(
    '/api/products',
    (data) => console.log('Produtos carregados:', data),
    (err) => console.error('Erro ao carregar produtos:', err)
  );

  return loading ? <div>Carregando...</div> : <div>{products?.length} produtos</div>;
}

function ArticleList() {
  const { data: articles, loading } = useFetch(
    '/api/articles',
    (data) => analytics.track('articles_loaded', { count: data.length }),
    (err) => notificationService.showError('Falha ao carregar artigos')
  );

  return loading ? <div>Carregando...</div> : <div>{articles?.length} artigos</div>;
}

Separação de Concerns em Prática

Isolando Lógica de Apresentação da Lógica de Negócio

Separação de concerns significa que cada entidade do seu código tem uma razão única para mudar. Hooks customizados são o mecanismo perfeito para extrair a lógica de negócio dos componentes, deixando estes últimos focados apenas em renderização e interação com o usuário.

Considere um formulário de autenticação. A lógica de validação, chamadas à API e gerenciamento de estado são preocupações de negócio. A renderização de inputs, botões e mensagens de erro são preocupações de apresentação. Separando-as:

// Concern 1: Lógica de autenticação (pode ser testado sem React)
function useAuthLogic(onAuthSuccess) {
  const [credentials, setCredentials] = useState({ email: '', password: '' });
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const validate = useCallback((email, password) => {
    const newErrors = {};
    if (!email.includes('@')) newErrors.email = 'Email inválido';
    if (password.length < 8) newErrors.password = 'Mínimo 8 caracteres';
    return newErrors;
  }, []);

  const handleSubmit = useCallback(async (e) => {
    e?.preventDefault();
    const newErrors = validate(credentials.email, credentials.password);
    setErrors(newErrors);

    if (Object.keys(newErrors).length === 0) {
      setIsSubmitting(true);
      try {
        const response = await fetch('/api/auth/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(credentials)
        });
        const data = await response.json();
        if (!response.ok) throw new Error(data.message);

        localStorage.setItem('token', data.token);
        onAuthSuccess(data.user);
      } catch (err) {
        setErrors({ submit: err.message });
      } finally {
        setIsSubmitting(false);
      }
    }
  }, [credentials, validate, onAuthSuccess]);

  return {
    credentials,
    setCredentials,
    errors,
    isSubmitting,
    handleSubmit
  };
}

// Concern 2: Renderização e interação com usuário
function LoginForm() {
  const { credentials, setCredentials, errors, isSubmitting, handleSubmit } = 
    useAuthLogic((user) => {
      // Navegar ou atualizar contexto global
      console.log('Usuário autenticado:', user);
    });

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          type="email"
          value={credentials.email}
          onChange={(e) => setCredentials(prev => ({
            ...prev,
            email: e.target.value
          }))}
          placeholder="Email"
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>

      <div>
        <input
          type="password"
          value={credentials.password}
          onChange={(e) => setCredentials(prev => ({
            ...prev,
            password: e.target.value
          }))}
          placeholder="Senha"
        />
        {errors.password && <span className="error">{errors.password}</span>}
      </div>

      {errors.submit && <div className="error">{errors.submit}</div>}

      <button disabled={isSubmitting}>
        {isSubmitting ? 'Autenticando...' : 'Entrar'}
      </button>
    </form>
  );
}

Hooks para Gerenciamento de Estado Derivado

Muitas vezes você precisa derivar novo estado a partir de um estado existente. Criar um hook para isso mantém essa transformação isolada e testável, evitando cálculos repetidos no componente.

// Hook que encapsula lógica de derivação de estado
function useFilteredAndSortedItems(items, filterFn, sortFn) {
  const filtered = useMemo(
    () => items.filter(filterFn),
    [items, filterFn]
  );

  const sorted = useMemo(
    () => [...filtered].sort(sortFn),
    [filtered, sortFn]
  );

  const stats = useMemo(
    () => ({
      total: items.length,
      filtered: filtered.length,
      percentage: (filtered.length / items.length * 100).toFixed(1)
    }),
    [items.length, filtered.length]
  );

  return { sorted, stats };
}

// Componente focado apenas em renderização
function ProductCatalog({ products }) {
  const { sorted, stats } = useFilteredAndSortedItems(
    products,
    (product) => product.price < 100,
    (a, b) => a.name.localeCompare(b.name)
  );

  return (
    <div>
      <p>Mostrando {stats.filtered} de {stats.total} produtos ({stats.percentage}%)</p>
      <ul>
        {sorted.map(product => (
          <li key={product.id}>{product.name} - R${product.price}</li>
        ))}
      </ul>
    </div>
  );
}

Padrões Avançados e Melhores Práticas

Composição de Hooks com Custom Reducers

Para lógica mais complexa envolvendo múltiplos estados interdependentes, combinar hooks com reducers é uma abordagem poderosa que mantém a lógica determinística e testável.

// Hook com reducer para gerenciar estado complexo
function usePaginatedData(fetchFn) {
  const initialState = {
    items: [],
    page: 1,
    pageSize: 10,
    total: 0,
    loading: false,
    error: null
  };

  const reducer = (state, action) => {
    switch (action.type) {
      case 'FETCH_START':
        return { ...state, loading: true, error: null };

      case 'FETCH_SUCCESS':
        return {
          ...state,
          items: action.payload.items,
          total: action.payload.total,
          loading: false
        };

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

      case 'SET_PAGE':
        return { ...state, page: action.payload };

      case 'SET_PAGE_SIZE':
        return { ...state, pageSize: action.payload, page: 1 };

      default:
        return state;
    }
  };

  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    dispatch({ type: 'FETCH_START' });
    fetchFn(state.page, state.pageSize)
      .then((data) => {
        dispatch({
          type: 'FETCH_SUCCESS',
          payload: data
        });
      })
      .catch((error) => {
        dispatch({
          type: 'FETCH_ERROR',
          payload: error.message
        });
      });
  }, [state.page, state.pageSize, fetchFn]);

  const goToPage = useCallback((page) => {
    dispatch({ type: 'SET_PAGE', payload: page });
  }, []);

  const changePageSize = useCallback((size) => {
    dispatch({ type: 'SET_PAGE_SIZE', payload: size });
  }, []);

  return {
    ...state,
    goToPage,
    changePageSize,
    totalPages: Math.ceil(state.total / state.pageSize)
  };
}

// Uso em um componente
function UserTable() {
  const { items, page, pageSize, loading, error, totalPages, goToPage, changePageSize } = 
    usePaginatedData(async (pageNum, size) => {
      const response = await fetch(`/api/users?page=${pageNum}&limit=${size}`);
      return response.json();
    });

  return (
    <div>
      {error && <div className="error">{error}</div>}
      {loading && <div>Carregando...</div>}

      <table>
        <tbody>
          {items.map(user => (
            <tr key={user.id}>
              <td>{user.name}</td>
              <td>{user.email}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <div>
        Página {page} de {totalPages}
        <button onClick={() => goToPage(page - 1)} disabled={page === 1}>← Anterior</button>
        <button onClick={() => goToPage(page + 1)} disabled={page === totalPages}>Próxima →</button>
      </div>

      <select value={pageSize} onChange={(e) => changePageSize(Number(e.target.value))}>
        <option value={5}>5 por página</option>
        <option value={10}>10 por página</option>
        <option value={20}>20 por página</option>
      </select>
    </div>
  );
}

Context + Hooks para Estado Global Sem Redux

Para estado realmente global sem a overhead do Redux, Context combinado com hooks customizados é elegante e suficiente para muitos casos.

// Criar o contexto
const ThemeContext = createContext();

// Hook customizado para usar o contexto
function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme deve ser usado dentro de ThemeProvider');
  }
  return context;
}

// Provider que encapsula a lógica de tema
function ThemeProvider({ children }) {
  const [isDark, setIsDark] = useState(() => {
    return localStorage.getItem('theme') === 'dark';
  });

  const toggleTheme = useCallback(() => {
    setIsDark(prev => {
      const newValue = !prev;
      localStorage.setItem('theme', newValue ? 'dark' : 'light');
      return newValue;
    });
  }, []);

  const theme = useMemo(() => ({
    isDark,
    colors: isDark
      ? { bg: '#1a1a1a', text: '#ffffff' }
      : { bg: '#ffffff', text: '#000000' }
  }), [isDark]);

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

// Uso em qualquer componente
function App() {
  const { isDark, colors, toggleTheme } = useTheme();

  return (
    <div style={{ backgroundColor: colors.bg, color: colors.text }}>
      <p>Tema escuro: {isDark ? 'Sim' : 'Não'}</p>
      <button onClick={toggleTheme}>Alternar tema</button>
    </div>
  );
}

// No topo da árvore
function Root() {
  return (
    <ThemeProvider>
      <App />
    </ThemeProvider>
  );
}

Testando Hooks Customizados

Hooks customizados são facilmente testáveis quando bem separados. A biblioteca @testing-library/react fornece renderHook exatamente para isso.

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

describe('useAuthLogic', () => {
  it('valida email corretamente', () => {
    const { result } = renderHook(() => useAuthLogic(() => {}));

    expect(result.current.errors.email).toBeUndefined();

    act(() => {
      result.current.setCredentials({ email: 'invalido', password: 'senha123456' });
    });

    act(() => {
      result.current.handleSubmit();
    });

    expect(result.current.errors.email).toBe('Email inválido');
  });

  it('envia dados corretos para a API', async () => {
    const mockFetch = jest.spyOn(global, 'fetch').mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({ token: 'abc123', user: { id: 1 } })
    });

    const onSuccess = jest.fn();
    const { result } = renderHook(() => useAuthLogic(onSuccess));

    act(() => {
      result.current.setCredentials({ email: 'user@email.com', password: 'senha123456' });
    });

    await act(async () => {
      await result.current.handleSubmit();
    });

    expect(mockFetch).toHaveBeenCalledWith(
      '/api/auth/login',
      expect.objectContaining({
        method: 'POST',
        body: JSON.stringify({ email: 'user@email.com', password: 'senha123456' })
      })
    );

    expect(onSuccess).toHaveBeenCalledWith({ id: 1 });
    mockFetch.mockRestore();
  });
});

Conclusão

Durante este artigo, exploramos três pilares essenciais para dominar hooks customizados. Primeiro, compreender que composição e separação de concerns não são apenas boas práticas — são a base para código escalável e sustentável. Um hook bem construído é pequeno, testável e reutilizável porque tem uma responsabilidade única e bem definida. Segundo, invertendo o controle através de callbacks e propondo abstrações genéricas, você cria hooks que servem múltiplos casos de uso sem repetir lógica. Terceiro, os padrões avançados como reducers e Context mostram que hooks customizados vão muito além de um simples wrapper: eles são o mecanismo através do qual você constrói arquiteturas React profissionais, sem necessidade de bibliotecas externas para problemas bem delimitados.

Referências


Artigos relacionados