Vue 3 Composition API Essentials and State Management with Pinia

Project Initialization

Modern Vue development relies on the official scaffolding tool to bootstrap projects with optimal defaults. Running the initialization command triggers an interactive prompt that configures TypeScript, testing frameworks, router integration, and package managers.

npm create vue@latest

The generated project structure separates concerns clearly, placing application entry points, routing configuration, and state management in dedicated directories. This approach streamlines development workflows and enforces modular architecture from the start.

Reactivity Fundamentals

Vue 3 replaces the Options API with the Composision API, offering superior logic reuse and type inference. The framework provides two primary methods for creating reactive state:

  • ref() wraps primitive values or objects and requires .value for access and mutation within JavaScript.
  • reactive() creates a deep reactive proxy for objects, allowing direct property access without .value.
<script setup lang="ts">
import { ref, reactive } from 'vue'

const itemCount = ref(0)

const increment = () => {
  itemCount.value += 1
}

const uiConfig = reactive({
  theme: 'dark',
  sidebarOpen: true
})
</script>

<template>
  <div>
    <p>Total interactions: {{ itemCount }}</p>
    <button @click="increment">Update Count</button>
  </div>
</template>

Computed Properties and Watchers

Derived state should be handled through computed properties. They cache results based on dependencies and only re-evaluate when those dependencies change. Avoid mutating computed values directly; instead, use a setter if two-way binding is required.

<script setup lang="ts">
import { ref, computed } from 'vue'

const taskList = ref([1, 2, 3, 4, 5, 6, 7, 8, 9])

const filteredTasks = computed(() => {
  return taskList.value.filter(id => id > 4)
})
</script>

Side effects and data synchronization are managed via watch(). It supports shallow tracking by default but can monitor nested structures using the deep option. The immediate flag triggers the callback during component setup.

<script setup lang="ts">
import { ref, watch } from 'vue'

const searchQuery = ref('')

// Shallow watch on a single ref
watch([searchQuery], (newVal, oldVal) => {
  console.log('Search updated:', newVal, oldVal)
})

// Deep watch with immediate execution
const formState = ref({ user: { name: '', email: '' } })

watch(formState, (newVal) => {
  console.log('Form state changed deeply', newVal)
}, { deep: true, immediate: true })
</script>

Lifecycle Hooks

In the Composition API, lifecycle hooks are imported and prefixed with on. The setup function execution replaces beforeCreate and created. Other hooks align closely with their Options API counterparts.

<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'

onMounted(() => {
  console.log('Component rendered and mounted to DOM')
})

onBeforeUnmount(() => {
  console.log('Cleaning up timers and event listeners')
})
</script>

Component Communication and Macros

Parent-to-child data flow utilizes defineProps(), which compiles to TypeScript interfaces or runtime validation. Child-to-parent communication relies on defineEmits() to dispatch custom events.

Parent Component

<script setup>
import UserCard from './UserCard.vue'

const handleRoleUpdate = (role) => {
  console.log('Received role:', role)
}
</script>

<template>
  <UserCard 
    title="Administrator" 
    :is-active="true" 
    @role-changed="handleRoleUpdate" 
  />
</template>

Child Component

<script setup>
const props = defineProps({
  title: { type: String, required: true },
  isActive: { type: Boolean, default: false }
})

const emit = defineEmits(['role-changed'])

const updateRole = () => {
  emit('role-changed', 'editor')
}
</script>

Accessing DOM elements or child component instances requires template refs. Child components must explicitly expose internal methods using defineExpose().

<script setup>
import { ref, onMounted } from 'vue'

const inputRef = ref(null)

defineExpose({
  focusInput: () => inputRef.value?.focus()
})

onMounted(() => {
  console.log(inputRef.value)
})
</script>

<template>
  <input ref="inputRef" type="text" placeholder="Type here" />
</template>

For cross-component state sharing without prop drilling, provide() and inject() establish a dependancy injection system.

Ancestor

<script setup>
import { provide } from 'vue'
provide('appLocale', 'en-US')
</script>

Descendant

<script setup>
import { inject } from 'vue'
const locale = inject('appLocale')
</script>

Vue 3 introduces several compiler macros:

  • defineOptions: Configures component metadata like name or inheritAttrs.
  • defineModel: Simplifies two-way binding with v-model by automatically handling props and emits.
  • defineProps, defineEmits, defineExpose: Type-safe, compile-time macros that remove the need for explicit imports.
<script setup>
import { ref } from 'vue'

const toggleState = defineModel<boolean>()
</script>

<template>
  <button @click="toggleState = !toggleState">
    Active: {{ toggleState }}
  </button>
</template>

State Management with Pinia

Pinia serves as the official state management solution, offering a simpler API and superior TypeScript support compared to Vuex.

Application Entry

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { setupStore } from './store'

const app = createApp(App)
app.use(router)
app.use(setupStore())
app.mount('#app')

Store Configuration Create a centralized store registry. Persistence plugins can be attached to automatically sync state with local storage.

import { createPinia } from 'pinia'
import piniaPluginPersist from 'pinia-plugin-persistedstate'
import { useAppSettings } from './modules/settings'

export { useAppSettings }

const pinia = createPinia()
pinia.use(piniaPluginPersist)

export default pinia

Module Definition Stores encapsulate state, getters, and actions. The persist option enables automatic serialization.

import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useAppSettings = defineStore('settings', () => {
  const fontSize = ref(14)
  const isDarkMode = ref(false)

  const toggleTheme = () => {
    isDarkMode.value = !isDarkMode.value
  }

  return {
    fontSize,
    isDarkMode,
    toggleTheme
  }
}, {
  persist: true
})

Component Integration When destructuring store state, use storeToRefs() to preserve reactivity. Actions can be called directly.

<script setup>
import { storeToRefs } from 'pinia'
import { useAppSettings } from '@/store'

const settingsStore = useAppSettings()
const { isDarkMode } = storeToRefs(settingsStore)

const applyTheme = () => {
  settingsStore.toggleTheme()
}
</script>

<template>
  <div :class="{ 'dark-theme': isDarkMode }">
    <button @click="applyTheme">Toggle Theme</button>
  </div>
</template>

Defining type interfaces alongside stores ensures compile-time safety for state shapes and action payloads. Structuring stores by feature domain rather than global singletons improves scalability and maintainability in larger applications.

Tags: vue3 composition-api Pinia frontend-development javascript

Posted on Thu, 09 Jul 2026 17:19:57 +0000 by mysty