Vue.js 3 Composition API : Guide Pratique 2024
La Composition API de Vue.js 3 révolutionne la façon dont nous développons des applications Vue. Après avoir migré plusieurs projets de Vue 2 vers Vue 3, voici mon guide pratique complet.
1. Introduction à la Composition API
Pourquoi la Composition API ?
// Options API (Vue 2)
export default {
data() {
return {
users: [],
loading: false,
error: null
}
},
methods: {
async fetchUsers() {
this.loading = true;
try {
const response = await fetch('/api/users');
this.users = await response.json();
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
}
},
mounted() {
this.fetchUsers();
}
}
// Composition API (Vue 3)
import { ref, onMounted } from 'vue'
export default {
setup() {
const users = ref([])
const loading = ref(false)
const error = ref(null)
const fetchUsers = async () => {
loading.value = true
try {
const response = await fetch('/api/users')
users.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
onMounted(fetchUsers)
return {
users,
loading,
error,
fetchUsers
}
}
}
2. Réactivité avec ref() et reactive()
Utilisation de ref()
import { ref, computed } from 'vue'
export default {
setup() {
// Données primitives
const count = ref(0)
const message = ref('Hello Vue 3!')
// Données complexes
const user = ref({
name: 'John Doe',
email: 'john@example.com'
})
// Computed properties
const doubleCount = computed(() => count.value * 2)
// Méthodes
const increment = () => {
count.value++
}
return {
count,
message,
user,
doubleCount,
increment
}
}
}
Utilisation de reactive()
import { reactive, computed } from 'vue'
export default {
setup() {
// Objet réactif
const state = reactive({
users: [],
filters: {
search: '',
status: 'active'
},
pagination: {
page: 1,
limit: 10,
total: 0
}
})
// Computed basé sur reactive
const filteredUsers = computed(() => {
return state.users.filter(user =>
user.name.toLowerCase().includes(state.filters.search.toLowerCase()) &&
user.status === state.filters.status
)
})
return {
state,
filteredUsers
}
}
}
3. Composables Réutilisables
Composable useApi()
// composables/useApi.js
import { ref } from 'vue'
export function useApi() {
const loading = ref(false)
const error = ref(null)
const data = ref(null)
const execute = async (apiCall) => {
loading.value = true
error.value = null
try {
const response = await apiCall()
data.value = response.data
return response
} catch (err) {
error.value = err.response?.data?.message || err.message
throw err
} finally {
loading.value = false
}
}
const reset = () => {
loading.value = false
error.value = null
data.value = null
}
return {
loading,
error,
data,
execute,
reset
}
}
Composable useLocalStorage()
// composables/useLocalStorage.js
import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue = null) {
const storedValue = localStorage.getItem(key)
const initialValue = storedValue ? JSON.parse(storedValue) : defaultValue
const value = ref(initialValue)
// Synchroniser avec localStorage
watch(
value,
(newValue) => {
if (newValue === null) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(newValue))
}
},
{ deep: true }
)
return value
}
Composable useForm()
// composables/useForm.js
import { reactive, computed } from 'vue'
export function useForm(initialData = {}, validationRules = {}) {
const form = reactive({
data: { ...initialData },
errors: {},
touched: {}
})
const isValid = computed(() => {
return Object.keys(form.errors).length === 0
})
const isDirty = computed(() => {
return Object.keys(form.touched).length > 0
})
const validate = (field = null) => {
if (field) {
validateField(field)
} else {
Object.keys(validationRules).forEach(validateField)
}
}
const validateField = (field) => {
const rules = validationRules[field]
const value = form.data[field]
if (rules) {
const error = rules.find(rule => !rule.test(value))
if (error) {
form.errors[field] = error.message
} else {
delete form.errors[field]
}
}
}
const setField = (field, value) => {
form.data[field] = value
form.touched[field] = true
validate(field)
}
const reset = () => {
form.data = { ...initialData }
form.errors = {}
form.touched = {}
}
return {
form,
isValid,
isDirty,
validate,
setField,
reset
}
}
4. Gestion d’État avec Pinia
Store Pinia avec Composition API
// stores/userStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '@/composables/useApi'
export const useUserStore = defineStore('user', () => {
// State
const users = ref([])
const currentUser = ref(null)
const { loading, error, execute } = useApi()
// Getters
const activeUsers = computed(() =>
users.value.filter(user => user.status === 'active')
)
const getUserById = computed(() => {
return (id) => users.value.find(user => user.id === id)
})
// Actions
const fetchUsers = async () => {
await execute(async () => {
const response = await fetch('/api/users')
const data = await response.json()
users.value = data
return { data }
})
}
const createUser = async (userData) => {
await execute(async () => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
})
const newUser = await response.json()
users.value.push(newUser)
return { data: newUser }
})
}
const updateUser = async (id, userData) => {
await execute(async () => {
const response = await fetch(`/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
})
const updatedUser = await response.json()
const index = users.value.findIndex(u => u.id === id)
if (index !== -1) {
users.value[index] = updatedUser
}
return { data: updatedUser }
})
}
return {
// State
users,
currentUser,
loading,
error,
// Getters
activeUsers,
getUserById,
// Actions
fetchUsers,
createUser,
updateUser
}
})
5. Composants Avancés avec Script Setup
Composant de Liste avec Filtres
<!-- components/UserList.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/userStore'
import { useLocalStorage } from '@/composables/useLocalStorage'
// Props
defineProps({
title: {
type: String,
default: 'Liste des utilisateurs'
}
})
// Emits
const emit = defineEmits(['user-selected'])
// Stores
const userStore = useUserStore()
// State local
const searchQuery = ref('')
const selectedStatus = useLocalStorage('user-filter-status', 'all')
const sortBy = useLocalStorage('user-sort-by', 'name')
const sortOrder = useLocalStorage('user-sort-order', 'asc')
// Computed
const filteredAndSortedUsers = computed(() => {
let filtered = userStore.users
// Filtrage par recherche
if (searchQuery.value) {
filtered = filtered.filter(user =>
user.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
user.email.toLowerCase().includes(searchQuery.value.toLowerCase())
)
}
// Filtrage par statut
if (selectedStatus.value !== 'all') {
filtered = filtered.filter(user => user.status === selectedStatus.value)
}
// Tri
filtered.sort((a, b) => {
let aVal = a[sortBy.value]
let bVal = b[sortBy.value]
if (typeof aVal === 'string') {
aVal = aVal.toLowerCase()
bVal = bVal.toLowerCase()
}
const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0
return sortOrder.value === 'asc' ? comparison : -comparison
})
return filtered
})
const statusOptions = [
{ value: 'all', label: 'Tous' },
{ value: 'active', label: 'Actif' },
{ value: 'inactive', label: 'Inactif' },
{ value: 'pending', label: 'En attente' }
]
// Methods
const selectUser = (user) => {
emit('user-selected', user)
}
const toggleSort = (field) => {
if (sortBy.value === field) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
sortBy.value = field
sortOrder.value = 'asc'
}
}
// Lifecycle
onMounted(() => {
userStore.fetchUsers()
})
</script>
<template>
<div class="user-list">
<header class="user-list__header">
<h2>{{ title }}</h2>
<div class="filters">
<input
v-model="searchQuery"
type="text"
placeholder="Rechercher..."
class="search-input"
/>
<select v-model="selectedStatus" class="status-filter">
<option
v-for="option in statusOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</header>
<div v-if="userStore.loading" class="loading">
Chargement...
</div>
<div v-else-if="userStore.error" class="error">
{{ userStore.error }}
</div>
<table v-else class="user-table">
<thead>
<tr>
<th @click="toggleSort('name')" class="sortable">
Nom
<span v-if="sortBy === 'name'">
{{ sortOrder === 'asc' ? '↑' : '↓' }}
</span>
</th>
<th @click="toggleSort('email')" class="sortable">
Email
<span v-if="sortBy === 'email'">
{{ sortOrder === 'asc' ? '↑' : '↓' }}
</span>
</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in filteredAndSortedUsers"
:key="user.id"
@click="selectUser(user)"
class="user-row"
>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>
<span :class="`status status--${user.status}`">
{{ user.status }}
</span>
</td>
<td>
<button @click.stop="$emit('edit-user', user)">
Éditer
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
/* Styles du composant */
.user-list {
padding: 1rem;
}
.user-list__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.filters {
display: flex;
gap: 1rem;
}
.search-input,
.status-filter {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.user-table {
width: 100%;
border-collapse: collapse;
}
.user-table th,
.user-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #eee;
}
.sortable {
cursor: pointer;
user-select: none;
}
.sortable:hover {
background-color: #f5f5f5;
}
.user-row {
cursor: pointer;
}
.user-row:hover {
background-color: #f9f9f9;
}
.status {
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 500;
}
.status--active {
background-color: #d4edda;
color: #155724;
}
.status--inactive {
background-color: #f8d7da;
color: #721c24;
}
.status--pending {
background-color: #fff3cd;
color: #856404;
}
.loading,
.error {
text-align: center;
padding: 2rem;
}
.error {
color: #dc3545;
}
</style>
6. Tests avec Composition API
Test d’un Composable
// tests/composables/useForm.test.js
import { describe, it, expect } from 'vitest'
import { useForm } from '@/composables/useForm'
describe('useForm', () => {
it('should initialize with default values', () => {
const initialData = { name: '', email: '' }
const { form, isValid } = useForm(initialData)
expect(form.data).toEqual(initialData)
expect(form.errors).toEqual({})
expect(form.touched).toEqual({})
expect(isValid.value).toBe(true)
})
it('should validate fields correctly', () => {
const initialData = { email: '' }
const rules = {
email: [
{
test: (value) => value.includes('@'),
message: 'Email invalide'
}
]
}
const { form, setField, isValid } = useForm(initialData, rules)
setField('email', 'invalid-email')
expect(form.errors.email).toBe('Email invalide')
expect(isValid.value).toBe(false)
setField('email', 'valid@email.com')
expect(form.errors.email).toBeUndefined()
expect(isValid.value).toBe(true)
})
})
7. Migration Vue 2 vers Vue 3
Checklist de Migration
- Mise à jour des dépendances
- Remplacement des filtres par des computed
- Migration vers Composition API
- Mise à jour du router et du store
- Tests et validation
Exemple de Migration
// Avant (Vue 2)
export default {
filters: {
currency(value) {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(value)
}
}
}
// Après (Vue 3)
import { computed } from 'vue'
export default {
setup() {
const formatCurrency = (value) => {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(value)
}
return {
formatCurrency
}
}
}
Conclusion
Vue.js 3 avec la Composition API offre une approche plus flexible et maintenable pour développer des applications modernes. La réutilisabilité des composables et la meilleure organisation du code en font un choix excellent pour les projets complexes.
Questions sur Vue.js 3 ? Contactez-moi pour un audit ou de l’accompagnement technique.
Mathieu Mont - Développeur Fullstack Vue.js, React, PHP Symfony