Capítulo 08: Criação de Composables Reutilizáveis (Custom Vue Hooks)

Especialização em Front-End com Vue • Vue 3 & Nuxt 3 • Composition API, Pinia, Vue Router, SSR e TypeScript


🗺️ Mapa Conceitual da Arquitetura de Composables

flowchart TD
    A["Regra de Negócio / Estado / Efeito"] --> B["Custom Composable: useMinhaLogica()"]
    B --> C["Estado Reativo Interno (ref / reactive)"]
    B --> D["Derivações e Cálculos (computed)"]
    B --> E["Listeners / Timers / I/O Externo"]

    E --> F["onScopeDispose: Limpeza Automática"]
    C & D --> G["Retorno de Objeto de Refs Desestruturável"]

    G --> H["Componente A (UI)"]
    G --> I["Componente B (UI)"]
    G --> J["Store do Pinia / EffectScope"]

    style A fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px
    style B fill:#fff3e0,stroke:#ff9800,stroke-width:2px
    style F fill:#fce4ec,stroke:#e91e63,stroke-width:2px
    style G fill:#e8f5e9,stroke:#4caf50,stroke-width:2px

🏛️ 1. O Padrão Canônico de Composables no Vue 3

Um Composable (equivalente funcional aos Custom Hooks do React) é uma função JavaScript que encapsula e reutiliza lógica de estado com recursos da Composition API do Vue. Ao contrário dos legados Mixins do Vue 2, os composables possuem origem 100% transparente de dados, não sofrem com colisões de nomes e integram-se perfeitamente com o compilador do TypeScript.

1.1 Convenções de Nomenclatura e Retorno

  1. Prefixo use...: Toda função composable deve iniciar com o termo use, denotando que faz uso do ciclo de reatividade (ex: useUserSession, useWindowScroll, useDebounce).
  2. Retorno de Objeto com Refs: Sempre retorne um objeto JavaScript regular contendo propriedades do tipo Ref<T> individuais, e não um objeto reativo fechado. Isso permite que os componentes consumidores utilizem desestruturação livre (const { data, isLoading } = useMinhaApi()) sem perder a reatividade de cada variável.

1.2 Injeção de Dependência Tipada: provide() e inject()

Para passar configurações ou instâncias através de árvores profundas de componentes sem sofrer com Prop Drilling, utiliza-se o mecanismo de injeção de dependência do Vue. Para garantir segurança de tipos em TypeScript, deve-se utilizar InjectionKey<T>:

// types/keys.ts
import type { InjectionKey, Ref } from 'vue';
export const UserSessionKey: InjectionKey<Ref<UserSession>> = Symbol('UserSessionKey');

// Componente Raiz:
provide(UserSessionKey, sessionRef);

// Componente Profundo / Composable:
const session = inject(UserSessionKey);
if (!session) throw new Error('UserSessionKey não provido no contexto ancestral.');

1.3 onScopeDispose e Descarte de Recursos

Em composables robustos, utiliza-se onScopeDispose no lugar de onUnmounted. Enquanto onUnmounted só funciona quando o composable é chamado diretamente dentro do ciclo de vida de um componente SFC, o onScopeDispose é capaz de limpar listeners e timers mesmo quando o composable é instanciado dentro de stores do Pinia, testes do Vitest ou blocos isolados com effectScope().


⚖️ 2. Tabela Comparativa: Mixins (Vue 2) vs Composables (Vue 3)

Critério de Avaliação Mixins Legados (Vue 2) Composables Modernos (Vue 3)
Origem das Propriedades Implícita e opaca; difícil saber de qual mixin veio uma variável. Explícita; os valores retornados são desestruturados no script.
Colisão de Nomes Frequente; dois mixins com a mesma chave sobrescrevem um ao outro. Impossível; variáveis podem ser renomeadas na desestruturação.
Integração com TypeScript Praticamente inviável sem any ou casting manual. Total e Automática; contratos de retorno inferidos estaticamente.
Suporte a Parâmetros Não suporta passagem de argumentos na inicialização. Suporta múltiplos parâmetros e opções dinâmicas.

⚠️ 3. Armadilhas Comuns em Produção (Gotchas & Anti-Patterns)

Anti-Pattern 1: Chamar Composables de Forma Assíncrona ou Condicional

Erro Comum:

// ❌ ANTI-PATTERN GRAVE:
async function load() {
  await fetchConfig();
  const { isOnline } = useNetwork(); // ERRO: Chamado fora da execução síncrona de setup()!
}

Regra Fundamental: Composables que registram hooks de ciclo de vida (onMounted, onScopeDispose) ou leem contexto (inject) devem ser invocados sincronamente no corpo principal de <script setup>.

Anti-Pattern 2: Retornar Objeto reactive Diretamente

Erro Comum: Retornar return reactive({ x, y }). Se o consumidor fizer const { x } = useMouse(), x perde o vínculo com a reatividade do Proxy. Abordagem Correta: Retorne return { x, y } onde x e y são instâncias de ref(), ou aplique toRefs() no retorno.


💻 4. Código Conceitual Arquitetural Comentado

// src/composables/useWindowScrollPosition.ts
import { ref, onScopeDispose, type Ref } from 'vue';

export interface ScrollPosition {
  x: Ref<number>;
  y: Ref<number>;
  isScrolledDown: Ref<boolean>;
}

export function useWindowScrollPosition(thresholdPx = 100): ScrollPosition {
  const x = ref<number>(0);
  const y = ref<number>(0);
  const isScrolledDown = ref<boolean>(false);

  // Verificação defensiva de ambiente para suporte pleno ao SSR / Nuxt 3
  if (typeof window !== 'undefined') {
    const updateScroll = () => {
      x.value = window.scrollX;
      y.value = window.scrollY;
      isScrolledDown.value = window.scrollY > thresholdPx;
    };

    window.addEventListener('scroll', updateScroll, { passive: true });
    updateScroll(); // Captura a posição inicial

    // onScopeDispose: Limpa o listener automaticamente ao destruir o escopo reativo
    onScopeDispose(() => {
      console.log('[useWindowScrollPosition] Listener de scroll descartado.');
      window.removeEventListener('scroll', updateScroll);
    });
  }

  return { x, y, isScrolledDown };
}

🛠️ 5. Cheat-Sheet de Boas Práticas para Composables

// 1. Definição com generics e tipagem estrita
export function useFetchData<T>(url: string | Ref<string>) {
  const data = ref<T | null>(null);
  const isPending = ref(false);

  // Aceita tanto string estática quanto Ref reativa (toValue / unref)
  const targetUrl = computed(() => toValue(url));

  // 2. Retorno desestruturável
  return { data, isPending };
}

// 3. Consumo no componente
const { data, isPending } = useFetchData<Usuario[]>('/api/usuarios');