Logic Abstraction with Composables
Abstracting logic
Abstraction means identifying a responsibility and providing a simpler way to use it. In Vue, a composable can hide the details of a reactive operation behind an API designed for its consumer.
The goal is not moving code merely to shrink a component. An extraction is useful when it clarifies intent, allows an implementation to vary, or avoids repeating a responsibility.
Finding a responsibility
Before extracting code, try to describe what it does as an independent capability:
- Filter products from a search query.
- Paginate a collection.
- Load an asynchronous resource.
- Validate form fields.
“Handle the entire catalog” is still too broad. useProductSearch communicates a clearer boundary than useCatalogStuff.
Incremental extraction
A component can begin with perfectly valid local logic.
<script setup lang="ts">
import { computed, ref } from "vue";
const products = ref([
{ id: 1, name: "Keyboard" },
{ id: 2, name: "Monitor" },
]);
const query = ref("");
const filteredProducts = computed(() =>
products.value.filter((product) => product.name.toLowerCase().includes(query.value.toLowerCase())),
);
</script>
When search gains its own rules or appears in another view, we can extract that responsibility without changing the interface.
Explicit inputs
An abstraction becomes more reusable when it receives what it needs instead of importing hidden dependencies.
import { computed, ref, type Ref } from "vue";
type Product = {
id: number;
name: string;
};
export function useProductSearch(products: Ref<Product[]>) {
const query = ref("");
const filteredProducts = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase();
return products.value.filter((product) =>
product.name.toLowerCase().includes(normalizedQuery),
);
});
function clearSearch() {
query.value = "";
}
return {
query,
filteredProducts,
clearSearch,
};
}
The composable does not decide where products come from or how they are displayed. It receives a reactive source and owns only search behavior.
A use-oriented API
The public API should describe what consumers can do:
queryrepresents the current input.filteredProductscommunicates a derived result.clearSearchexpresses a complete action.
There is no need to expose every helper, cache, or intermediate value. The more internal details a component knows, the harder it becomes to change the implementation without breaking it.
<script setup lang="ts">
const products = ref([]);
const { query, filteredProducts, clearSearch } = useProductSearch(products);
</script>
Replaceable dependencies
A data-loading composable can receive its external operation as an argument. This keeps it independent from fetch, a URL, or a specific service.
export function useProducts(loadProducts: () => Promise<Product[]>) {
const products = ref<Product[]>([]);
const loading = ref(false);
async function load() {
loading.value = true;
try {
products.value = await loadProducts();
} finally {
loading.value = false;
}
}
return { products, loading, load };
}
The application can provide a real service and tests can provide a controlled function. The abstraction keeps its responsibility without knowing the infrastructure.
Composing composables
A complex feature does not need to become one giant composable. Several small capabilities can collaborate.
const { products, load } = useProducts(productService.list);
const search = useProductSearch(products);
const pagination = usePagination(search.filteredProducts, 12);
Each piece keeps a focused contract. A component or higher-level composable can orchestrate them for a particular use case.
The cost of abstraction
Every abstraction adds a name, file, API, and extra jump while reading. Extracting logic too early can produce:
- Generic composables with unclear purposes.
- Parameters and options no consumer needs yet.
- Contracts that are difficult to change because they anticipate imaginary future cases.
- Logic fragmented across too many small files.
Duplication can be a signal, but it is not a rigid formula. A complex responsibility used once may still deserve extraction; two similar fragments may evolve in different directions.
Testing the contract
A good abstraction can be tested through its public API.
const products = ref([
{ id: 1, name: "Keyboard" },
{ id: 2, name: "Monitor" },
]);
const { query, filteredProducts } = useProductSearch(products);
query.value = "key";
expect(filteredProducts.value).toEqual([{ id: 1, name: "Keyboard" }]);
The test does not need internal helpers or a mounted interface. If the implementation changes while the contract remains stable, consumers continue to work.
Decision guide
| Question | Favorable signal |
|---|---|
| Does the logic have a recognizable responsibility? | It can receive a specific name |
| Can its dependencies be expressed as inputs? | It does not need hidden context imports |
| Does the API simplify the consumer? | It exposes fewer concepts than the implementation |
| Can it evolve without knowing the UI? | It does not depend on markup or styles |
| Does extraction reduce the cost of change? | It centralizes rules that should evolve together |
General rule
Abstract a responsibility, not an accidental set of lines. Define explicit inputs and a small API when extraction reduces what the consumer needs to understand.