Composable — Encapsulating Reusable Logic
In this chapter, you will learn Vue3's best practice — using composable functions to encapsulate reusable logic, making code cleaner and easier to maintain.
What is a Composable?
As features increase, the JS logic inside components grows longer. Both the home page and the detail page need to load article data, so the code is duplicated twice.
Composable (composable function)It extracts reusable logic into independent JS functions, and whichever component needs it can call it there.
Its essence isan ordinary function that returns reactive data and methods.。
Why do you need Composable?
Compare the code structure before and after the change:
| Without Composable (before) | With Composable (after) |
|---|---|
| Each component writes its own fetch logic | One usePosts() is reused everywhere |
| Data loading and UI logic are mixed together | Data logic is independent, components only care about UI |
| Changing one piece of logic requires modifying multiple files | Change one place, all components update synchronously |
| Component code quickly bloats as business grows | Component code has a single responsibility, and its size is controllable |
If you have used Vue2's Mixins, Composable can be understood as a clearer and safer alternative — it has no naming conflicts, and the data source is transparent.
Naming Conventions and Rules
| Rule | Description | Example |
|---|---|---|
withusePrefix | Know at a glance that this is a Composable | usePosts、useDarkMode |
Put incomposables/directory | Easy to find and maintain | src/composables/usePosts.js |
| Return reactive data | Return ref or reactive, keep the caller reactive | return { articles, isLoading } |
| One function does only one thing | Single responsibility, easy to compose | usePosts only handles data, useDarkMode only handles themes |
usePosts — Encapsulating Article Data Logic
This Composable is responsible for:Loading data + category filtering + search filtering。
Example
import { ref, computed, onMounted } from 'vue'
export function usePosts() {
const articles = ref([]) // All article data
const isLoading = ref(true) // Loading state
const error = ref(null) // Error message
const activeCategory = ref('All') // Currently selected category
const keyword = ref('') // Search keyword
// Extract all categories (deduplicate)
const categories = computed(() => {
const cats = articles.value.map(a => a.category)
return ['All', ...new Set(cats)]
})
// Filtered articles: first by category, then by keyword
const filteredArticles = computed(() => {
let result = articles.value
// Filter by category
if (activeCategory.value !== 'All') {
result = result.filter(a => a.category === activeCategory.value)
}
// Filter by keyword (match title or summary)
if (keyword.value.trim()) {
const kw = keyword.value.trim().toLowerCase()
result = result.filter(a =>
a.title.toLowerCase().includes(kw) ||
a.summary.toLowerCase().includes(kw)
)
}
return result
})
// Find a single article by ID
function getArticleById(id) {
return articles.value.find(a => a.id === id)
}
// Switch category
function setCategory(cat) {
activeCategory.value = cat
}
// Load data
async function fetchPosts() {
isLoading.value = true
error.value = null
try {
const res = await fetch('/posts.json')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
articles.value = await res.json()
} catch (err) {
error.value = err.message
} finally {
isLoading.value = false
}
}
// Automatically load when component is mounted
onMounted(() => {
fetchPosts()
})
// Return data and methods for components to use
return {
articles,
isLoading,
error,
activeCategory,
keyword,
categories,
filteredArticles,
getArticleById,
setCategory,
fetchPosts
}
}
Now the home page component becomes very concise, just call usePosts:
Example
<script setup>
import { usePosts } from '../composables/usePosts.js'
import BlogCard from '../components/BlogCard.vue'
import CategoryFilter from '../components/CategoryFilter.vue'
// Get all article-related data and methods with one line of code
const {
isLoading,
error,
activeCategory,
keyword,
categories,
filteredArticles,
setCategory,
fetchPosts
} = usePosts()
</script>
<template>
<div class="home">
<div class="search-bar">
<input
v-model="keyword"
type="text"
placeholder="Search article title or summary..."
/>
</div>
<CategoryFilter
:categories="categories"
:active-category="activeCategory"
@update-category="setCategory"
/>
<p v-if="isLoading">Loading...</p>
<p v-else-if="error">Failed to load: {{ error }}
<button @click="fetchPosts">Retry</button>
</p>
<p v-else-if="filteredArticles.length === 0">No matching articles</p>
<div v-else class="article-grid">
<BlogCard
v-for="article in filteredArticles"
:key="article.id"
:id="article.id"
:title="article.title"
:summary="article.summary"
:date="article.date"
:category="article.category"
/>
</div>
</div>
</template>
The detail page can also reuse usePosts without writing fetch again. Note that you need to import it separately from Vuecomputed:
Example
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { usePosts } from '../composables/usePosts.js'
const route = useRoute()
const id = Number(route.params.id)
const { isLoading, error, getArticleById } = usePosts()
// articles is reactive data, computed will automatically recalculate after fetch completes
const article = computed(() => getArticleById(id))
</script>
<template>
<div>
<p v-if="isLoading">Loading...</p>
<p v-else-if="error">Failed to load: {{ error }}</p>
<div v-else-if="!article">Article does not exist</div>
<article v-else>
<h1>{{ article.title }}</h1>
<div v-html="article.content"></div>
</article>
</div>
</template>
Note: If the user directly accesses the detail page URL (e.g., refreshing the page), usePosts will fetch data again. Because the home page and detail page are different component instances, each call to usePosts executes the fetch in onMounted, and data is not shared. If you need to share the same data across pages, you can lift the state up to a Pinia store, or call it once in a top-level component and pass it down via provide / inject.
useDarkMode — Toggle Dark Mode with One Line of Code
The core idea of dark mode: on thehtmltag, add a class, then use CSS variables to control colors.
The user's choice is stored in localStorage and automatically restored on the next visit.
Example
import { ref, watchEffect } from 'vue'
export function useDarkMode() {
// Read the user's previous setting from localStorage (default to light if none)
const saved = localStorage.getItem('blog-theme')
const isDark = ref(saved === 'dark')
// Apply theme to DOM
function applyTheme(dark) {
if (dark) {
document.documentElement.classList.add('dark')
localStorage.setItem('blog-theme', 'dark')
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('blog-theme', 'light')
}
}
// watchEffect executes once immediately to apply the initial theme
// After that, whenever isDark changes, automatically sync to DOM
watchEffect(() => {
applyTheme(isDark.value)
})
// Toggle dark mode
function toggleDark() {
isDark.value = !isDark.value
}
return { isDark, toggleDark }
}
To use it in NavBar, only two lines are needed:
Example
<script setup>
import { useDarkMode } from '../composables/useDarkMode.js'
const { isDark, toggleDark } = useDarkMode()
</script>
<template>
<header class="navbar">
<a href="/" class="logo">EXAMPLE Blog</a>
<nav>
<a href="/">Home</a>
<button class="theme-btn" @click="toggleDark">
{{ isDark ? '☀`Light' : '☾ Dark' }}`
</button>
</nav>
</header>
</template>
Combine with Global CSS Variables to Implement Theme Switching
Example
/* Light mode (default) */
:root {
--bg-primary: #f5f5f5;
--bg-card: #ffffff;
--text-primary: #333333;
--text-secondary:#666666;
--border-color: #eeeeee;
}
/* Dark mode (applies when the html tag has the .dark class) */
html.dark {
--bg-primary: #1a1a2e;
--bg-card: #16213e;
--text-primary: #e0e0e0;
--text-secondary:#a0a0a0;
--border-color: #2a2a4a;
}
/* Reference these variables in components, no need to modify any component code when switching themes */
body {
background: var(--bg-primary);
color: var(--text-primary);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
}
At this point, clicking the toggle button will instantly switch the colors of the entire site, and your choice will be remembered after refresh.
Comparison with Vue2 Mixin
| Feature | Mixin(Vue2) | Composable(Vue3) |
|---|---|---|
| Data source | Opaque, you don't know where the properties come from | Explicitly get via destructuring from return, the source is clear at a glance |
| Naming conflict | Same-named properties from multiple Mixins will override each other | You decide the variable names, no conflicts exist |
| Type inference | Poor TypeScript support | Native TypeScript support, automatic type inference |
| Logic organization | Categorized by options (data/methods/computed) | Categorized by feature, one composable per file |
| Logic reuse | Mixins are hard to combine and tend to create implicit dependencies | Composables can call each other, flexible composition |