Vue.js 3 Composition API : Guide Pratique 2024 avec Exemples Concrets

Maîtrisez Vue.js 3 Composition API avec ce guide complet. Exemples pratiques, patterns avancés, et bonnes pratiques de développeur expérimenté.

Image de l'article : Vue.js 3 Composition API : Guide Pratique 2024 avec Exemples Concrets

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

  1. Mise à jour des dépendances
  2. Remplacement des filtres par des computed
  3. Migration vers Composition API
  4. Mise à jour du router et du store
  5. 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