From data format adaptation to route synchronization, diving into the design principles and logic reuse of complex components
This article covers: flexible data format support (object arrays, string arrays, number arrays), custom field mapping (labelKey/valueKey), route state synchronization (pagination + filters bound to URL), keyboard navigation (without interfering with input fields), logic extraction and reuse (composables), and industrial-grade details: type backtracking, defensive programming, and extreme edge case handling.
I. Introduction
If writing the Button component is about enjoying the "paint-like beauty" of CSS variables, then writing Select and Pagination is about wrestling in the "quagmire" of native HTML legacy baggage. Simple components are one-way data consumers, while complex components are data adapters (supporting multiple formats) and state synchronizers (coordinating with routing and keyboard events).
This article uses Select and Pagination as examples to demonstrate the development approach for complex components, covering:
- Flexible data format support (object arrays, string arrays, number arrays)
- Custom field mapping (
labelKey/valueKey) - Route state synchronization (page numbers and filters bound to URL)
- Accessibility features (keyboard navigation, mobile gestures)
- Logic extraction and reuse (composables)
- Industrial-grade details: type backtracking, defensive programming, event pollution prevention, extreme edge case handling
II. Select Dropdown: The Data Adapter
The Select component needs to receive a list of options and allow the user to select one. Real-world APIs may return object arrays, string arrays, or even number arrays, so the component must have robust data adaptation capabilities.
2.1 Requirements Analysis
- Support object arrays
{ label, value }(default) - Support custom field names (
labelKey/valueKey) - Support string arrays
['Option A', 'Option B'] - Support number arrays
[1, 2, 3] - Provide a placeholder (non-selectable default option)
- Support disabled options (
disabled: true) - Support error state, sizing, disabled, and other common attributes
- Must solve the native
<select>type trap (always returns strings) - Support native
<form>submission (vianameattribute)
2.2 API Design and Type Preservation
To avoid type pollution from any, use union types to narrow the scope.
type SelectOption = string | number | Record<string, any>
interface Props {
modelValue?: string | number
options?: SelectOption[] // Union type, rejects bare any
labelKey?: string // Default 'label'
valueKey?: string // Default 'value'
placeholder?: string
name?: string // Supports native form submission
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
error?: boolean
}
2.3 Internal Implementation: Defensive Programming + Type Backtracking
<template>
<select
class="mg-select"
:class="[`mg-select-${size}`, { 'mg-select-error': error }]"
:value="modelValue"
:disabled="disabled"
:name="name"
v-bind="$attrs"
@change="handleChange"
>
<option v-if="placeholder" value="" disabled hidden>
{{ placeholder }}
</option>
<option
v-for="item in options || []"
:key="getValue(item)"
:value="getValue(item)"
:disabled="item.disabled"
>
{{ getLabel(item) }}
</option>
</select>
</template>
<script setup lang="ts">
// ... type definitions
const getLabel = (item: any): string => {
if (item === null || item === undefined) return ''
if (typeof item !== 'object') return String(item)
const label = item[props.labelKey]
return label !== undefined ? String(label) : String(item)
}
const getValue = (item: any): any => {
if (item === null || item === undefined) return undefined
if (typeof item !== 'object') return item
const val = item[props.valueKey]
return val !== undefined ? val : item
}
// π₯ Core: Solving the fatal issue where native select always returns strings
const handleChange = (event: Event) => {
const target = event.target as HTMLSelectElement
const rawValue = target.value
// Attempt to find the original type (number or object value) from the original options
const originalItem = (props.options || []).find(item => String(getValue(item)) === rawValue)
const finalValue = originalItem !== undefined ? getValue(originalItem) : rawValue
emit('update:modelValue', finalValue)
emit('change', finalValue)
}
</script>
Defensive Programming Note: options || [] ensures that even if undefined is passed in externally or loading is delayed, the component won't break. The type backtracking logic ensures that number values bound via v-model won't accidentally become strings. Explicit support for the name attribute allows <select> to participate in native form submission.
2.4 Usage Examples
<!-- Object array (default fields) -->
<Select v-model="category" :options="categories" name="category" />
<!-- Custom field names -->
<Select v-model="category" :options="cats" label-key="name" value-key="id" />
<!-- String array (type preserved automatically) -->
<Select v-model="color" :options="['Red', 'Green', 'Blue']" />
<!-- Number array -->
<Select v-model="num" :options="[10, 20, 30]" />
<!-- Disabled options -->
<Select v-model="status" :options="statusOptions" />
III. Pagination Component: The State Synchronizer
The Pagination component needs to work with the current page number and items per page, providing previous/next buttons and supporting direct input for page jumps. In Nuxt projects, the page number often needs to be synchronized with the URL, and keyboard left/right arrow navigation must be supported without interfering with input fields.
3.1 Requirements Analysis
- Display
current page / total pages - Previous/next buttons (disabled at boundaries)
- Clicking the current page number turns it into an input field for direct page jumps
- Synchronize with route query (update URL when page changes)
- Support keyboard left/right arrow navigation (must not interfere with input fields)
- Extreme edge case defense (empty input, out-of-range values, etc.)
- Size adaptation
3.2 API Design
interface Props {
currentPage: number // v-model:current-page
totalPages: number
size?: 'sm' | 'md' | 'lg'
}
3.3 Core Implementation (with Extreme Edge Case Defense)
<template>
<nav class="mg-pagination" :class="`mg-pagination-${size}`">
<button class="mg-pagination-btn" :disabled="currentPage === 1" @click="goPrev">
Previous
</button>
<span v-if="!editing" class="mg-pagination-current" @click="startEdit">
{{ currentPage }}
</span>
<input
v-else
ref="inputRef"
v-model="inputPage"
type="number"
class="mg-pagination-input"
:min="1"
:max="totalPages"
@blur="commit"
@keyup.enter="commit"
/>
<span class="mg-pagination-sep">/</span>
<span class="mg-pagination-total">{{ totalPages }}</span>
<button class="mg-pagination-btn" :disabled="currentPage === totalPages" @click="goNext">
Next
</button>
</nav>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
const props = defineProps<{ currentPage: number; totalPages: number; size?: string }>()
const emit = defineEmits<{ 'update:currentPage': [page: number] }>()
const editing = ref(false)
const inputPage = ref(props.currentPage)
const inputRef = ref<HTMLInputElement>()
const goPrev = () => { if (props.currentPage > 1) emit('update:currentPage', props.currentPage - 1) }
const goNext = () => { if (props.currentPage < props.totalPages) emit('update:currentPage', props.currentPage + 1) }
const startEdit = () => {
editing.value = true
inputPage.value = props.currentPage
nextTick(() => inputRef.value?.focus())
}
const commit = () => {
editing.value = false
const raw = inputPage.value
const pageNum = parseInt(String(raw), 10)
// Invalid input: abandon without updating the page
if (isNaN(pageNum)) return
let page = Math.min(Math.max(pageNum, 1), props.totalPages)
if (page !== props.currentPage) {
emit('update:currentPage', page)
}
}
</script>
3.4 Keyboard Navigation Logic (Composable)
To keep the component clean, extract keyboard events into useKeyboardPagination, ensuring they don't interfere with input fields.
// composables/useKeyboardPagination.ts
import { onMounted, onUnmounted } from 'vue'
export function useKeyboardPagination(onPrev: () => void, onNext: () => void) {
const handleKeyDown = (e: KeyboardEvent) => {
// π₯ Critical: Don't trigger pagination if focus is in an input or textarea
const target = e.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return
if (e.key === 'ArrowLeft') onPrev()
if (e.key === 'ArrowRight') onNext()
}
onMounted(() => window.addEventListener('keydown', handleKeyDown))
onUnmounted(() => window.removeEventListener('keydown', handleKeyDown))
}
3.5 Route Synchronization and All-in-One Composable
To demonstrate the elegance of "one-click assembly", combine page route synchronization, keyboard events, and navigation methods into a single all-in-one composable.
// composables/useMoongatePagination.ts
import { ref, type Ref } from 'vue'
import { useRouteQueryNumber } from './useRouteQuery'
import { useKeyboardPagination } from './useKeyboardPagination'
export function useMoongatePagination(totalPages: Ref<number>) {
const page = useRouteQueryNumber('page', { defaultValue: 1 })
const goPrev = () => { if (page.value > 1) page.value-- }
const goNext = () => { if (page.value < totalPages.value) page.value++ }
// Keyboard events are assembled internally
useKeyboardPagination(goPrev, goNext)
return { page, goPrev, goNext }
}
Usage in a business page:
<script setup>
const totalPages = ref(10)
const { page, goPrev, goNext } = useMoongatePagination(totalPages)
// No need to handle keyboard events separately β everything is automatic
</script>
<template>
<Pagination v-model:current-page="page" :total-pages="totalPages" />
<!-- You can also use custom buttons linked to the pagination state -->
<button @click="goPrev">Previous</button>
<button @click="goNext">Next</button>
</template>
IV. Data and State Flow in Complex Components
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Complex Component Data Flow β
β βββββββββββββββ βββββββββββββββ βββββββββββββββ β
β β External β -> β Data β -> β Internal β β
β β Data β β Adapter β β State β β
β β (options) β β (getLabel/ β β (selected) β β
β βββββββββββββββ β getValue) β ββββββββ¬βββββββ β
β βββββββββββββββ β β
β β β
β βββββββββββββββ βββββββββββββββ βββββββββββββββ β
β β Global β <- β State β <- β User β β
β β Environment β β Synchronizerβ β Interaction β β
β β (route/key) β β (watch/ β β (click/ β β
β βββββββββββββββ β event) β β keyboard) β β
β βββββββββββββββ βββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
V. Testing Strategies for Complex Components
| Concern | Simple Components (Button) | Complex Components (Select / Pagination) |
|---|---|---|
| Testing Strategy | Snapshot testing, simple click event triggering | State snapshot combinations, edge case testing (page 0 / exceeding total pages), DOM focus and keyboard simulation testing, type backtracking robustness testing |
VI. Summary
| Concern | Simple Components (Button) | Complex Components (Select / Pagination) |
|---|---|---|
| Number of Props | Few (5-8) | Many (10+) |
| Data Format | Fixed (string) | Flexible (supports multiple arrays, configurable fields, type preservation) |
| State Management | No internal state | May have internal edit state, input visibility toggling, extreme edge case defense |
| External Dependencies | None | Routing, keyboard events, gestures |
| Logic Reuse | Not needed | Strongly recommended to extract composables, can integrate into all-in-one functions |
| Defensive Programming | Low | High (needs to handle undefined data, type backtracking, empty input recovery) |
| Native Form Integration | Auto-transparent name
|
Explicit name attribute support, can participate in <form> submission |
| Testing Strategy | Snapshot, event triggering | State combinations, edge cases, keyboard simulation, type backtracking |
An excellent complex component acts like a vacuum cleaner internally, accommodating all kinds of quirky backend data formats (via key mapping and type backtracking), while externally interacting with the global environment (routes, window keyboard events) with gentlemanly restraint. High cohesion, low coupling β this principle is fully embodied in both types of components.
This article is part of the **Vue 3 Component Library Development Guide* series.*
The original Chinese version is available on my blog: moongate.top.
Try the component library on npm: moongate-vue
Β© 2026 yuelinghuashu. This work is licensed under CC BY-NC 4.0.
Top comments (0)