# 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.

> Pagina: https://ui.eneawork.it/blog/vue-3-composables-riutilizzabili
> Markdown: https://ui.eneawork.it/blog/vue-3-composables-riutilizzabili.md

*2026-11-01*

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.

Misurato il 1 novembre 2026: il catalogo conta 111.985 componenti, 1.556 blocchi e 12.808 icone da 1.472 librerie, letti da /api/stats. Su 1.514 profili di libreria 10 riguardano Vue, 8 dei quali dichiarano licenza MIT: le librerie Vue del catalogo distribuiscono i propri helper con questa forma. Metodo: lettura di data/library_profiles.json e della risposta di /api/stats con uno script Node, sul repository EneUI.

## 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?

## 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.

## FAQ

### 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.
