Web Development

Vue.js 3 Composition API

Bui Van H

Bui Van H

Frontend Developer

05-01
14p
1k
#Vue.js#Composition API#TypeScript#Frontend
Vue.js 3 Composition API

Vue.js 3 Composition API

Khám phá Vue.js 3 Composition API và reactive system mạnh mẽ.

Setup và Basic Composition

VUE
<template>
  <div class="user-dashboard">
    <header class="dashboard-header">
      <h1>Welcome, {{ user.name }}!</h1>
      <button @click="toggleTheme" class="theme-toggle">
        {{ isDarkMode ? '🌞' : '🌙' }}
      </button>
    </header>

    <div class="stats-grid">
      <div v-for="stat in stats" :key="stat.id" class="stat-card">
        <h3>{{ stat.label }}</h3>
        <p class="stat-value">{{ formatNumber(stat.value) }}</p>
        <span class="stat-change" :class="stat.change >= 0 ? 'positive' : 'negative'">
          {{ stat.change >= 0 ? '+' : '' }}{{ stat.change }}%
        </span>
      </div>
    </div>

    <div class="actions">
      <input 
        v-model="searchQuery" 
        @input="debouncedSearch"
        placeholder="Search users..."
        class="search-input"
      />
      
      <button @click="fetchUsers" :disabled="loading" class="fetch-btn">
        {{ loading ? 'Loading...' : 'Refresh Data' }}
      </button>
    </div>

    <div class="user-list" v-if="filteredUsers.length">
      <TransitionGroup name="list" tag="div">
        <div 
          v-for="user in paginatedUsers" 
          :key="user.id" 
          class="user-card"
          @click="selectUser(user)"
        >
          <img :src="user.avatar" :alt="user.name" class="user-avatar" />
          <div class="user-info">
            <h4>{{ user.name }}</h4>
            <p>{{ user.email }}</p>
            <span class="user-role">{{ user.role }}</span>
          </div>
          <div class="user-actions">
            <button @click.stop="editUser(user)" class="edit-btn">Edit</button>
            <button @click.stop="deleteUser(user.id)" class="delete-btn">Delete</button>
          </div>
        </div>
      </TransitionGroup>
    </div>

    <div class="pagination" v-if="totalPages > 1">
      <button 
        v-for="page in totalPages" 
        :key="page"
        @click="currentPage = page"
        :class="{ active: currentPage === page }"
        class="page-btn"
      >
        {{ page }}
      </button>
    </div>

    <!-- Modal -->
    <Teleport to="body">
      <UserModal 
        v-if="showModal" 
        :user="selectedUser"
        @close="showModal = false"
        @save="handleUserSave"
      />
    </Teleport>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, nextTick } from 'vue';
import { debounce } from 'lodash-es';
import UserModal from './UserModal.vue';
import { useUserStore } from '@/stores/user';
import { useTheme } from '@/composables/useTheme';
import { useApi } from '@/composables/useApi';

interface User {
  id: number;
  name: string;
  email: string;
  avatar: string;
  role: string;
  isActive: boolean;
}

interface Stat {
  id: string;
  label: string;
  value: number;
  change: number;
}

// Stores
const userStore = useUserStore();

// Composables
const { isDarkMode, toggleTheme } = useTheme();
const { get, post, put, delete: deleteRequest, loading } = useApi();

// Reactive state
const users = ref<User[]>([]);
const searchQuery = ref('');
const currentPage = ref(1);
const pageSize = ref(10);
const selectedUser = ref<User | null>(null);
const showModal = ref(false);

// Reactive object for stats
const stats = reactive<Stat[]>([
  { id: 'total', label: 'Total Users', value: 0, change: 12.5 },
  { id: 'active', label: 'Active Users', value: 0, change: 8.2 },
  { id: 'new', label: 'New This Month', value: 0, change: -2.1 },
  { id: 'revenue', label: 'Revenue', value: 0, change: 15.3 }
]);

const user = computed(() => userStore.currentUser);

// Computed properties
const filteredUsers = computed(() => {
  if (!searchQuery.value) return users.value;
  
  const query = searchQuery.value.toLowerCase();
  return users.value.filter(user => 
    user.name.toLowerCase().includes(query) ||
    user.email.toLowerCase().includes(query) ||
    user.role.toLowerCase().includes(query)
  );
});

const paginatedUsers = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value;
  const end = start + pageSize.value;
  return filteredUsers.value.slice(start, end);
});

const totalPages = computed(() => 
  Math.ceil(filteredUsers.value.length / pageSize.value)
);

// Methods
const fetchUsers = async () => {
  try {
    const response = await get('/api/users');
    users.value = response.data;
    updateStats();
  } catch (error) {
    console.error('Failed to fetch users:', error);
  }
};

const updateStats = () => {
  stats[0].value = users.value.length;
  stats[1].value = users.value.filter(u => u.isActive).length;
  stats[2].value = users.value.filter(u => {
    const userDate = new Date(u.createdAt);
    const now = new Date();
    return userDate.getMonth() === now.getMonth() && 
           userDate.getFullYear() === now.getFullYear();
  }).length;
  stats[3].value = users.value.reduce((sum, u) => sum + (u.revenue || 0), 0);
};

const selectUser = (user: User) => {
  selectedUser.value = user;
  showModal.value = true;
};

const editUser = async (user: User) => {
  selectedUser.value = { ...user };
  showModal.value = true;
};

const deleteUser = async (userId: number) => {
  if (!confirm('Are you sure you want to delete this user?')) return;
  
  try {
    await deleteRequest(`/api/users/${userId}`);
    users.value = users.value.filter(u => u.id !== userId);
    updateStats();
  } catch (error) {
    console.error('Failed to delete user:', error);
  }
};

const handleUserSave = async (userData: User) => {
  try {
    if (userData.id) {
      // Update existing user
      const response = await put(`/api/users/${userData.id}`, userData);
      const index = users.value.findIndex(u => u.id === userData.id);
      if (index !== -1) {
        users.value[index] = response.data;
      }
    } else {
      // Create new user
      const response = await post('/api/users', userData);
      users.value.push(response.data);
    }
    
    updateStats();
    showModal.value = false;
  } catch (error) {
    console.error('Failed to save user:', error);
  }
};

const formatNumber = (num: number): string => {
  if (num >= 1000000) {
    return (num / 1000000).toFixed(1) + 'M';
  } else if (num >= 1000) {
    return (num / 1000).toFixed(1) + 'K';
  }
  return num.toString();
};

// Debounced search
const debouncedSearch = debounce(() => {
  currentPage.value = 1; // Reset to first page when searching
}, 300);

// Watchers
watch(searchQuery, () => {
  debouncedSearch();
});

watch(
  () => filteredUsers.value.length,
  (newLength) => {
    if (currentPage.value > Math.ceil(newLength / pageSize.value)) {
      currentPage.value = 1;
    }
  }
);

// Lifecycle
onMounted(async () => {
  await fetchUsers();
  
  // Focus search input after component mounts
  await nextTick();
  const searchInput = document.querySelector('.search-input') as HTMLInputElement;
  if (searchInput) {
    searchInput.focus();
  }
});
</script>

<style scoped>
.user-dashboard {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.dashboard-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 30px;
}

.theme-toggle {
  background: none;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  padding: 8px 12px;
  cursor: pointer;
  font-size: 18px;
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 20px;
  margin-bottom: 30px;
}

.stat-card {
  background: var(--card-bg);
  border-radius: 12px;
  padding: 20px;
  border: 1px solid var(--border-color);
}

.stat-value {
  font-size: 28px;
  font-weight: bold;
  margin: 10px 0;
}

.stat-change.positive { color: #10b981; }
.stat-change.negative { color: #ef4444; }

.actions {
  display: flex;
  gap: 15px;
  margin-bottom: 20px;
}

.search-input {
  flex: 1;
  padding: 12px 16px;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  font-size: 14px;
}

.user-list {
  display: grid;
  gap: 15px;
}

.user-card {
  display: flex;
  align-items: center;
  background: var(--card-bg);
  border: 1px solid var(--border-color);
  border-radius: 12px;
  padding: 20px;
  cursor: pointer;
  transition: all 0.2s ease;
}

.user-card:hover {
  border-color: var(--primary-color);
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.user-avatar {
  width: 50px;
  height: 50px;
  border-radius: 50%;
  margin-right: 15px;
}

.user-info {
  flex: 1;
}

.user-actions {
  display: flex;
  gap: 10px;
}

.pagination {
  display: flex;
  justify-content: center;
  gap: 10px;
  margin-top: 30px;
}

.page-btn {
  padding: 8px 12px;
  border: 1px solid var(--border-color);
  background: var(--card-bg);
  border-radius: 6px;
  cursor: pointer;
}

.page-btn.active {
  background: var(--primary-color);
  color: white;
  border-color: var(--primary-color);
}

/* Transitions */
.list-enter-active,
.list-leave-active {
  transition: all 0.3s ease;
}

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(-30px);
}

.list-move {
  transition: transform 0.3s ease;
}
</style>

Vue.js Development

Custom Composables

TypeScript
// composables/useApi.ts
import { ref, readonly } from 'vue';
import axios from 'axios';

interface ApiResponse<T = any> {
  data: T;
  message?: string;
  status: number;
}

export function useApi() {
  const loading = ref(false);
  const error = ref<string | null>(null);

  const apiCall = async <T>(
    method: 'GET' | 'POST' | 'PUT' | 'DELETE',
    url: string,
    data?: any
  ): Promise<ApiResponse<T>> => {
    loading.value = true;
    error.value = null;

    try {
      const response = await axios({
        method,
        url,
        data,
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${localStorage.getItem('token')}`
        }
      });

      return {
        data: response.data,
        status: response.status,
        message: response.data.message
      };
    } catch (err: any) {
      error.value = err.response?.data?.message || err.message;
      throw err;
    } finally {
      loading.value = false;
    }
  };

  const get = <T>(url: string) => apiCall<T>('GET', url);
  const post = <T>(url: string, data: any) => apiCall<T>('POST', url, data);
  const put = <T>(url: string, data: any) => apiCall<T>('PUT', url, data);
  const deleteRequest = <T>(url: string) => apiCall<T>('DELETE', url);

  return {
    loading: readonly(loading),
    error: readonly(error),
    get,
    post,
    put,
    delete: deleteRequest
  };
}
TypeScript
// composables/useTheme.ts
import { ref, watch } from 'vue';

export function useTheme() {
  const isDarkMode = ref(false);

  // Initialize theme from localStorage
  const savedTheme = localStorage.getItem('theme');
  if (savedTheme) {
    isDarkMode.value = savedTheme === 'dark';
  } else {
    // Check system preference
    isDarkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches;
  }

  const applyTheme = (dark: boolean) => {
    document.documentElement.classList.toggle('dark', dark);
    document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
  };

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

  // Watch for changes and apply theme
  watch(
    isDarkMode,
    (newValue) => {
      applyTheme(newValue);
      localStorage.setItem('theme', newValue ? 'dark' : 'light');
    },
    { immediate: true }
  );

  return {
    isDarkMode,
    toggleTheme
  };
}

Pinia Store

TypeScript
// stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { User } from '@/types/user';

export const useUserStore = defineStore('user', () => {
  // State
  const currentUser = ref<User | null>(null);
  const users = ref<User[]>([]);
  const isAuthenticated = ref(false);

  // Getters
  const userCount = computed(() => users.value.length);
  const activeUsers = computed(() => 
    users.value.filter(user => user.isActive)
  );
  
  const isAdmin = computed(() => 
    currentUser.value?.role === 'admin'
  );

  // Actions
  const setCurrentUser = (user: User | null) => {
    currentUser.value = user;
    isAuthenticated.value = !!user;
  };

  const addUser = (user: User) => {
    users.value.push(user);
  };

  const updateUser = (updatedUser: User) => {
    const index = users.value.findIndex(u => u.id === updatedUser.id);
    if (index !== -1) {
      users.value[index] = updatedUser;
    }
  };

  const removeUser = (userId: number) => {
    users.value = users.value.filter(u => u.id !== userId);
  };

  const login = async (credentials: { email: string; password: string }) => {
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials)
      });

      if (response.ok) {
        const data = await response.json();
        setCurrentUser(data.user);
        localStorage.setItem('token', data.token);
        return data;
      } else {
        throw new Error('Login failed');
      }
    } catch (error) {
      console.error('Login error:', error);
      throw error;
    }
  };

  const logout = () => {
    setCurrentUser(null);
    localStorage.removeItem('token');
  };

  return {
    // State
    currentUser,
    users,
    isAuthenticated,
    
    // Getters
    userCount,
    activeUsers,
    isAdmin,
    
    // Actions
    setCurrentUser,
    addUser,
    updateUser,
    removeUser,
    login,
    logout
  };
});

Vue 3 tip: Sử dụng Composition API cho logic reusable và TypeScript support tốt hơn!

Kết luận

Vue.js 3 Composition API mang lại flexibility và type safety tuyệt vời cho modern web development.

Bui Van H

Bui Van H

Frontend Developer chuyên Vue.js và modern JavaScript.