React

Composable riutilizzabili in Vue 3: contratto, test e limiti

Un composable riutilizzabile ha tre regole: dipendenze passate come argomenti, stato creato dentro la chiamata, cleanup registrato negli hook. Il resto è stile.

In sintesi

  • Un composable che crea stato dentro la funzione non condivide nulla fra due chiamate.
  • Le dipendenze vanno passate come ref o getter, non lette da un modulo globale.
  • Il cleanup in onUnmounted o onCleanup evita richieste e listener orfani.

Un composable riutilizzabile è una funzione che accetta le sue dipendenze come argomenti, crea stato nuovo a ogni chiamata e registra il cleanup negli hook di Vue. Se rispetta queste tre regole si può spostare fra progetti senza modifiche.

Il contratto minimo

// composables/useLocalStorage.ts
import { ref, watch } from "vue";

export function useLocalStorage(key: string, initial: T) {
  const stored = localStorage.getItem(key);
  const state = ref(stored ? JSON.parse(stored) : initial);

  watch(
    state,
    (value) => localStorage.setItem(key, JSON.stringify(value)),
    { deep: true },
  );

  return state;
}

La chiave arriva da fuori, lo stato nasce dentro. Due componenti che chiamano useLocalStorage("theme", "light") hanno due ref distinti che leggono la stessa chiave: è il comportamento voluto per una preferenza locale, non per un carrello.

Dipendenze esplicite

import { ref, watch } from "vue";

export function useDebounced(source: () => T, delay = 300) {
  const value = ref(source());
  let timer: ReturnType | undefined;

  watch(source, (next) => {
    clearTimeout(timer);
    timer = setTimeout(() => (value.value = next), delay);
  });

  return value;
}

// usage: useDebounced(() => query.value)

Una funzione getter come () => query.value tiene il composable indipendente dal nome della variabile. Se invece leggesse un ref importato da un modulo, il test dovrebbe ricostruire quel modulo e il riuso fra progetti si romperebbe.

Test senza montare un componente

import { describe, expect, it } from "vitest";
import { useLocalStorage } from "./useLocalStorage";

describe("useLocalStorage", () => {
  it("reads the stored value", () => {
    localStorage.setItem("theme", JSON.stringify("dark"));
    expect(useLocalStorage("theme", "light").value).toBe("dark");
  });
});

Finché il composable usa solo ref, computed e watch, il test è una chiamata di funzione. Quando entrano onMounted o onUnmounted serve un contesto di componente e il test passa da @vue/test-utils.

Composable o store?

CasoVincePerché
Formattazione di una dataComposableFunzione pura, nessuno stato condiviso
Listener su una media queryComposableStato locale alla vista che lo usa
Utente autenticatoPiniaServe una sola istanza per tutta l’app
Stato creato fuori dalla funzionePiniaUn modulo condiviso è uno store non dichiarato

Il limite da mettere in conto

Un composable non ha un ordine di inizializzazione garantito rispetto agli altri: se due funzioni leggono la stessa chiave di localStorage al mount, vince l’ultima che scrive. Per uno stato che deve essere uno solo, la soluzione è uno store, non un composable con un ref di modulo.

Dove continuare

Nel catalogo: shadcn-vue, PrimeVue e BootstrapVue Next; i componenti con stato locale sono in components. Le guide: Composition API e Pinia. Il confronto: regole degli hook in React.

Domande frequenti

Un composable può creare stato condiviso?

Sì, ma solo se lo stato è creato fuori dalla funzione, a livello di modulo. Chiamato dentro la funzione, ogni chiamata crea uno stato nuovo.

Dove metto il composable?

In una cartella composables, con un nome che inizia per use. Il nome non è magia: serve a chi legge per riconoscere una funzione che usa la Composition API.

Come lo testo senza montare un componente?

Chiamalo dentro un effetto o un contesto di componente minimo. Con ref semplici basta Vitest, serve un componente quando usi hook di ciclo di vita.

Meglio composable o Pinia?

Composable per logica riusata senza stato condiviso, Pinia per stato che sopravvive alla navigazione. Se il valore deve essere uno solo, serve lo store.

Perché il cleanup non parte?

Se la funzione è chiamata fuori dal setup, onUnmounted non ha istanza. Passa un AbortController e annullalo nel cleanup di watch.