log
Swift Code Chronicles

[Vue 3] Generic Components: Building Type-Safe, Highly Reusable UI Components

Published on May 10, 2025
Updated on May 10, 2025
40 min read
Vue

In modern front-end development, TypeScript, with its powerful type system, provides a solid foundation for building large-scale, robust applications.

Among these advancements, Generic Components, introduced in Vue 3.3, represent a significant milestone. They allow us to define and use type parameters in Vue components, much like using generics in TypeScript functions or classes. This greatly enhances component reusability and type safety.

1. What are Generic Components?

If you’re familiar with TypeScript, you’re likely already acquainted with Generics. Generics allow you to define functions, interfaces, or classes without pre-specifying concrete types, instead specifying them at the time of use.

// Example of a generic function in TypeScript
function identity<T>(arg: T): T {
  return arg;
}

let output1 = identity<string>("hello"); // T is specified as string
let output2 = identity<number>(123);    // T is specified as number

Vue 3’s Generic Components introduce this concept into .vue Single File Components (SFCs). By adding the generic attribute to the <script setup> tag, you can declare one or more type parameters and use them in props, emits, and even within the component’s internal logic.

2. How to Define a Generic Component?

In Vue 3.3+, defining generic components using the <script setup> syntax sugar is very intuitive. Below, we’ll demonstrate how to use Vue 3 Generic Components with a more concrete GenericTable component. This table component will be able to receive data of different types, along with corresponding column definitions, and display them in a table format.

2.1 Defining Data Types

export type User = {
  userId: string;
  userName: string;
  email: string;
  isActive: boolean;
};

export type Manager = {
  managerId: number;
  managerName: string;
  department: string;
  level: number;
};

2.2 Creating the Generic Table Component

This component will use the generic type T to represent the data type of each row in the table. It also requires a columns prop to define how the data should be displayed.

<script setup lang="ts" generic="T extends { [key: string]: any }">
// Declare generic parameter T using generic="T"
// Adding extends { [key: string]: any } is a basic constraint, indicating T is an object
// You can also add more specific constraints if needed (e.g., T extends { id: string | number })

import type { VNode } from 'vue'; // If the render function might return VNode

// 1. Define the interface for column configuration, also using generics
// This interface describes how each column extracts and displays data from an object of type T
export interface ColumnDefinition<ItemType> {
  key: keyof ItemType; // Key point: Use keyof to ensure the key exists in ItemType
  header: string;       // Text displayed in the header
  // Optional custom render function, providing maximum flexibility
  render?: (item: ItemType) => VNode | string | number | boolean;
}

// 2. Define Props, using generic T and ColumnDefinition<T>
const props = defineProps<{
  items: T[];                             // Table data, an array of type T
  columns: ColumnDefinition<T>[];         // Array of column definitions
  itemKey?: keyof T;                      // Optional: Specify the property name to be used for the row's :key
}>();

// 3. Helper function: Get the content to display in a cell
const getCellValue = (item: T, column: ColumnDefinition<T>) => {
  if (column.render) {
    // If there's a custom render function, use it
    return column.render(item);
  }
  // Otherwise, access the property value directly
  // Vue templates automatically handle most basic types
  return item[column.key];
};

// 4. Helper function: Get the unique key for a row
const getRowKey = (item: T, index: number): string | number => {
  if (props.itemKey && (typeof item[props.itemKey] === 'string' || typeof item[props.itemKey] === 'number')) {
    // If a valid itemKey prop is provided and its corresponding value is a string or number, use it
     return item[props.itemKey] as string | number;
  }
  // Otherwise, fall back to using the index (necessary when there's no unique ID, but having a unique ID is better)
  return index;
};

</script>

<template>
  <table class="generic-table">
    <thead>
      <tr>
        <th v-for="column in columns" :key="String(column.key)">
          {{ column.header }}
        </th>
      </tr>
    </thead>
    <tbody>
      <template v-if="items.length > 0">
        <tr v-for="(item, index) in items" :key="getRowKey(item, index)">
          <td v-for="column in columns" :key="String(column.key)">
            <span>{{ getCellValue(item, column) }}</span>
          </td>
        </tr>
      </template>
      <tr v-else>
        <td :colspan="columns.length || 1" class="no-data">
          No data available
        </td>
      </tr>
    </tbody>
  </table>
</template>

2.3 Using the Generic Table Component

Now, you can use the GenericTable in a parent component to display User and Manager data.

<script setup lang="ts">
import { ref } from 'vue';
// Assuming User and Manager types are exported from './types' or defined here
// For this example, let's assume they are imported from a separate types file or GenericTable.vue exports them.
// If GenericTable.vue exports them (as shown in its <script setup>):
import GenericTable, { type ColumnDefinition, type User, type Manager } from './GenericTable.vue';

// Or, if defined elsewhere (e.g. types.ts and re-exported by GenericTable.vue or directly imported here):
// import type { User, Manager } from './types';
// import GenericTable, { type ColumnDefinition } from './GenericTable.vue';


type UserColumns = ColumnDefinition<User>[];
type ManagerColumns = ColumnDefinition<Manager>[];


// 1. Prepare data
const users = ref<User[]>([
  { userId: 'USR001', userName: 'John Doe', email: 'john.doe@example.com', isActive: true },
  { userId: 'USR002', userName: 'Jane Smith', email: 'jane.smith@example.com', isActive: false },
  { userId: 'USR003', userName: 'Alex Wang', email: 'alex.wang@example.com', isActive: true },
]);

const managers = ref<Manager[]>([
  { managerId: 101, managerName: 'Chris Zhao', department: 'Tech Department', level: 3 },
  { managerId: 102, managerName: 'Pat Sun', department: 'Marketing Department', level: 4 },
]);

// 2. Define column configuration for the User table
const userColumns = ref<UserColumns>([
  { key: 'userId', header: 'User ID' },
  { key: 'userName', header: 'Name' },
  { key: 'email', header: 'Email' },
  {
    key: 'isActive',
    header: 'Status',
    // Use custom render function
    render: (user) => user.isActive ? 'Active' : 'Inactive'
  }
]);

// 3. Define column configuration for the Manager table
const managerColumns = ref<ManagerColumns>([
  { key: 'managerId', header: 'Manager ID' },
  { key: 'managerName', header: 'Name' },
  { key: 'department', header: 'Department' },
  {
    key: 'level',
    header: 'Level',
    render: (manager) => `Level ${manager.level}` // Simple formatting
  }
]);

</script>

<template>
  <div>
    <h2>User List</h2>
    <GenericTable
      :items="users"
      :columns="userColumns"
      itemKey="userId"
    />

    <hr style="margin: 2em 0;">

    <h2>Manager List</h2>
    <GenericTable
      :items="managers"
      :columns="managerColumns"
      itemKey="managerId"
    />
  </div>
</template>

3. Summary

Vue 3’s Generic Components are a powerful feature that seamlessly integrates TypeScript’s type system with Vue’s component model, making the construction of type-safe, highly reusable UI components simpler and more elegant than ever before. If you are using Vue 3 and TypeScript and aiming for higher quality, more maintainable code, then Generic Components are definitely worth understanding deeply and practicing in your projects!

About

A personal blog sharing technical insights, experiences and thoughts

Quick Links

Contact

  • Email: hushukang_blog@proton.me
  • GitHub

© 2025 Swift Code Chronicles. All rights reserved