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 logicOne usePosts() is reused everywhere
Data loading and UI logic are mixed togetherData logic is independent, components only care about UI
Changing one piece of logic requires modifying multiple filesChange one place, all components update synchronously
Component code quickly bloats as business growsComponent 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

RuleDescriptionExample
withusePrefixKnow at a glance that this is a ComposableusePosts、useDarkMode
Put incomposables/directoryEasy to find and maintainsrc/composables/usePosts.js
Return reactive dataReturn ref or reactive, keep the caller reactivereturn { articles, isLoading }
One function does only one thingSingle responsibility, easy to composeusePosts only handles data, useDarkMode only handles themes

usePosts — Encapsulating Article Data Logic

This Composable is responsible for:Loading data + category filtering + search filtering。

Example

// File path: src/composables/usePosts.js
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

<!-- File path: src/views/HomeView.vue -->
<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

<!-- File path: src/views/PostView.vue -->
<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

// File path: src/composables/useDarkMode.js
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

<!-- File path: src/components/NavBar.vue -->
<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 ? '&#x2600;`Light' : '☾ Dark' }}`
      </button>
    </nav>
  </header>
</template>

Combine with Global CSS Variables to Implement Theme Switching

Example

/* File path: src/assets/main.css */
/* 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

FeatureMixin(Vue2)Composable(Vue3)
Data sourceOpaque, you don't know where the properties come fromExplicitly get via destructuring from return, the source is clear at a glance
Naming conflictSame-named properties from multiple Mixins will override each otherYou decide the variable names, no conflicts exist
Type inferencePoor TypeScript supportNative TypeScript support, automatic type inference
Logic organizationCategorized by options (data/methods/computed)Categorized by feature, one composable per file
Logic reuseMixins are hard to combine and tend to create implicit dependenciesComposables can call each other, flexible composition
Other extensions