The massive parent component that owns everything — state, API calls, child logic, DOM refs. It’s the most common Vue architecture mistake and it sneaks up on every team. Here’s how to decompose it into something that doesn’t make you anxious to open.
It never starts big. That’s the insidious part.
You create a ProductList.vue. It fetches products. It has a search input. It filters. It renders a grid. Fine — forty lines, perfectly reasonable.
Then someone adds pagination. Then sorting. Then a “quick view” modal. Then inline editing. Then bulk selection with a “delete selected” action. Then a sidebar with filter controls. Then the search needs to debounce. Then the filters need to persist to the URL.
Six months later, ProductList.vue is 847 lines. It has 23 reactive variables. It has 14 computed properties. It has 11 functions. Every change you make to it requires reading half the file to understand the context. Every bug fix risks breaking something else because everything is entangled.
Nobody wrote a 847-line component on purpose. But most teams have at least one, and everyone on the team knows which file it is, and everyone avoids opening it if they can help it.
This post is about recognising the pattern before it gets to 847 lines — and the decomposition strategy that breaks it down into components and composables that are actually pleasant to work with.
The Anatomy of the God Component
Before fixing it, it’s worth understanding exactly what goes wrong. The god component typically has four intermingled concerns:
1. Data fetching and async state management
2. UI state (which modal is open, which tab is active, which items are selected)
3. Business logic (filtering, sorting, pagination calculations)
4. Rendering (the actual template and layout)
When all four live in one component, they corrupt each other. A rendering concern leaks into the business logic. A data fetching concern pulls in UI state. Everything is connected to everything, and nothing can be changed in isolation.
Here’s the real pattern — a condensed version of what these files look like:
<!-- ✗ ProductList.vue — the god component -->
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { Product, Filters, SortOption } from '@/types'
// ── Data fetching state ────────────────────────────────
const products = ref<Product[]>([])
const totalProducts = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
// ── Pagination state ───────────────────────────────────
const currentPage = ref(1)
const perPage = ref(20)
const totalPages = computed(() => Math.ceil(totalProducts.value / perPage.value))
// ── Filter state ───────────────────────────────────────
const searchQuery = ref('')
const debouncedQuery = ref('')
const selectedCategory = ref<string | null>(null)
const priceRange = ref<[number, number]>([0, 10000])
const inStockOnly = ref(false)
// ── Sort state ─────────────────────────────────────────
const sortBy = ref<SortOption>('newest')
// ── Selection state ────────────────────────────────────
const selectedIds = ref<Set<number>>(new Set())
const isAllSelected = computed(() =>
products.value.length > 0 &&
products.value.every(p => selectedIds.value.has(p.id))
)
// ── Modal state ────────────────────────────────────────
const quickViewProduct = ref<Product | null>(null)
const isQuickViewOpen = ref(false)
const isDeleteDialogOpen = ref(false)
const isEditModalOpen = ref(false)
const editingProduct = ref<Product | null>(null)
// ── DOM refs ───────────────────────────────────────────
const searchInputRef = ref<HTMLInputElement | null>(null)
const gridRef = ref<HTMLElement | null>(null)
const sidebarRef = ref<HTMLElement | null>(null)
// ── Router ─────────────────────────────────────────────
const route = useRoute()
const router = useRouter()
// ── 14 computed properties ─────────────────────────────
const filteredProducts = computed(() => { /* ... */ })
const activeFiltersCount = computed(() => { /* ... */ })
// ... 12 more
// ── Fetch function ──────────────────────────────────────
async function fetchProducts() { /* ... */ }
// ── 10 more functions ───────────────────────────────────
function toggleSelection(id: number) { /* ... */ }
function selectAll() { /* ... */ }
function clearSelection() { /* ... */ }
function openQuickView(product: Product) { /* ... */ }
function openEditModal(product: Product) { /* ... */ }
function handleDeleteSelected() { /* ... */ }
function handleSortChange(option: SortOption) { /* ... */ }
function syncFiltersToUrl() { /* ... */ }
function restoreFiltersFromUrl() { /* ... */ }
function handleSearchInput(value: string) { /* ... */ }
// ── Watchers ────────────────────────────────────────────
watch(searchQuery, /* debounce logic */ )
watch(currentPage, fetchProducts)
watch([selectedCategory, priceRange, inStockOnly, sortBy], () => {
currentPage.value = 1
fetchProducts()
syncFiltersToUrl()
})
onMounted(() => {
restoreFiltersFromUrl()
fetchProducts()
searchInputRef.value?.focus()
})
</script>
Twenty-three reactive variables. The rendering happens in a <template> that’s another 200 lines below this. Everything is in one file because “it’s all related to products.”
That last phrase is the trap. “All related to” is not the same as “should live together.”
The Decomposition Strategy
The correct decomposition has three steps:
- Extract data fetching and async state into a composable
- Extract UI state groups into focused composables
- Extract rendering concerns into child components
After decomposition, the parent component becomes an orchestrator — it holds composable instances and passes data down, but contains almost no logic itself.
Step 1: Extract Data Fetching into useProducts
The composable takes the filter state as arguments (reactive refs passed in), handles fetching, and returns only what the template needs.
// composables/useProducts.ts
import { ref, computed, watch, type Ref } from 'vue'
import type { Product, SortOption } from '@/types'
interface ProductFilters {
query: Ref<string>
category: Ref<string | null>
priceRange: Ref<[number, number]>
inStock: Ref<boolean>
sortBy: Ref<SortOption>
page: Ref<number>
perPage: Ref<number>
}
export function useProducts(filters: ProductFilters) {
const products = ref<Product[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const totalPages = computed(() =>
Math.ceil(total.value / filters.perPage.value)
)
async function fetch() {
loading.value = true
error.value = null
try {
const response = await productsApi.list({
q: filters.query.value,
category: filters.category.value,
minPrice: filters.priceRange.value[0],
maxPrice: filters.priceRange.value[1],
inStock: filters.inStock.value,
sort: filters.sortBy.value,
page: filters.page.value,
perPage: filters.perPage.value,
})
products.value = response.data
total.value = response.meta.total
} catch (err) {
error.value = (err as Error).message
} finally {
loading.value = false
}
}
// Re-fetch whenever any filter changes
watch(
[filters.query, filters.category, filters.priceRange,
filters.inStock, filters.sortBy, filters.page],
fetch,
{ immediate: true }
)
return { products, total, totalPages, loading, error, refetch: fetch }
}
Step 2: Extract Filter State into useProductFilters
// composables/useProductFilters.ts
import { ref, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDebouncedRef } from '@/composables/useDebouncedRef'
import type { SortOption } from '@/types'
export function useProductFilters() {
const route = useRoute()
const router = useRouter()
// Raw search input — debounced before hitting the API
const { immediate: searchInput, debounced: searchQuery } =
useDebouncedRef(String(route.query.q ?? ''), 350)
const selectedCategory = ref<string | null>(
(route.query.category as string) ?? null
)
const priceRange = ref<[number, number]>([
Number(route.query.minPrice ?? 0),
Number(route.query.maxPrice ?? 10000),
])
const inStockOnly = ref(route.query.inStock === 'true')
const sortBy = ref<SortOption>(
(route.query.sort as SortOption) ?? 'newest'
)
const currentPage = ref(Number(route.query.page ?? 1))
const perPage = ref(20)
const activeFiltersCount = computed(() => {
let count = 0
if (searchQuery.value) count++
if (selectedCategory.value) count++
if (inStockOnly.value) count++
if (priceRange.value[0] > 0 || priceRange.value[1] < 10000) count++
return count
})
// Sync filter state to URL for shareability and browser history
watch(
[searchQuery, selectedCategory, priceRange, inStockOnly, sortBy, currentPage],
() => {
router.replace({
query: {
...(searchQuery.value ? { q: searchQuery.value } : {}),
...(selectedCategory.value ? { category: selectedCategory.value } : {}),
...(inStockOnly.value ? { inStock: 'true' } : {}),
...(priceRange.value[0] > 0 ? { minPrice: priceRange.value[0] } : {}),
...(priceRange.value[1] < 10000 ? { maxPrice: priceRange.value[1] } : {}),
...(sortBy.value !== 'newest' ? { sort: sortBy.value } : {}),
...(currentPage.value > 1 ? { page: currentPage.value } : {}),
},
})
},
{ deep: true }
)
function reset() {
searchInput.value = ''
selectedCategory.value = null
priceRange.value = [0, 10000]
inStockOnly.value = false
sortBy.value = 'newest'
currentPage.value = 1
}
return {
searchInput, // bound to the input element (immediate)
searchQuery, // passed to the API (debounced)
selectedCategory,
priceRange,
inStockOnly,
sortBy,
currentPage,
perPage,
activeFiltersCount,
reset,
}
}
Step 3: Extract Selection State into useProductSelection
// composables/useProductSelection.ts
import { ref, computed, type Ref } from 'vue'
import type { Product } from '@/types'
export function useProductSelection(products: Ref<Product[]>) {
const selectedIds = ref<Set<number>>(new Set())
const selectedCount = computed(() => selectedIds.value.size)
const isAllSelected = computed(() =>
products.value.length > 0 &&
products.value.every(p => selectedIds.value.has(p.id))
)
const selectedProducts = computed(() =>
products.value.filter(p => selectedIds.value.has(p.id))
)
function toggle(id: number) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function selectAll() {
selectedIds.value = new Set(products.value.map(p => p.id))
}
function clear() {
selectedIds.value = new Set()
}
function isSelected(id: number) {
return selectedIds.value.has(id)
}
return {
selectedIds,
selectedCount,
isAllSelected,
selectedProducts,
toggle,
selectAll,
clear,
isSelected,
}
}
Step 4: Extract Modal State into useProductModals
// composables/useProductModals.ts
import { ref } from 'vue'
import type { Product } from '@/types'
export function useProductModals() {
const quickViewProduct = ref<Product | null>(null)
const editingProduct = ref<Product | null>(null)
const isDeleteDialogOpen = ref(false)
function openQuickView(product: Product) {
quickViewProduct.value = product
}
function closeQuickView() {
quickViewProduct.value = null
}
function openEditModal(product: Product) {
editingProduct.value = product
}
function closeEditModal() {
editingProduct.value = null
}
function openDeleteDialog() {
isDeleteDialogOpen.value = true
}
function closeDeleteDialog() {
isDeleteDialogOpen.value = false
}
return {
quickViewProduct,
editingProduct,
isDeleteDialogOpen,
openQuickView,
closeQuickView,
openEditModal,
closeEditModal,
openDeleteDialog,
closeDeleteDialog,
}
}
Step 5: The Slim Parent Component
After composable extraction, the parent component becomes a pure orchestrator:
<!-- ✓ ProductList.vue — the orchestrator -->
<script setup lang="ts">
import { useProductFilters } from '@/composables/useProductFilters'
import { useProducts } from '@/composables/useProducts'
import { useProductSelection } from '@/composables/useProductSelection'
import { useProductModals } from '@/composables/useProductModals'
// Each composable owns its domain — parent just wires them together
const filters = useProductFilters()
const data = useProducts(filters)
const selection = useProductSelection(data.products)
const modals = useProductModals()
async function handleDeleteSelected() {
await productsApi.bulkDelete([...selection.selectedIds.value])
selection.clear()
modals.closeDeleteDialog()
data.refetch()
}
</script>
<template>
<div class="product-list">
<!-- Filter sidebar -->
<ProductFilterSidebar
v-model:category="filters.selectedCategory.value"
v-model:price-range="filters.priceRange.value"
v-model:in-stock="filters.inStockOnly.value"
:active-count="filters.activeFiltersCount.value"
@reset="filters.reset"
/>
<!-- Main content area -->
<div class="product-list__main">
<!-- Toolbar -->
<ProductListToolbar
v-model:search="filters.searchInput.value"
v-model:sort="filters.sortBy.value"
:selected-count="selection.selectedCount.value"
:is-all-selected="selection.isAllSelected.value"
@select-all="selection.selectAll"
@clear-selection="selection.clear"
@delete-selected="modals.openDeleteDialog"
/>
<!-- Grid -->
<ProductGrid
:products="data.products.value"
:loading="data.loading.value"
:error="data.error.value"
:selected-ids="selection.selectedIds.value"
@toggle-selection="selection.toggle"
@quick-view="modals.openQuickView"
@edit="modals.openEditModal"
/>
<!-- Pagination -->
<ProductPagination
v-model:page="filters.currentPage.value"
:total-pages="data.totalPages.value"
:total="data.total.value"
/>
</div>
</div>
<!-- Modals — rendered at root to avoid stacking context issues -->
<ProductQuickView
v-if="modals.quickViewProduct.value"
:product="modals.quickViewProduct.value"
@close="modals.closeQuickView"
/>
<ProductEditModal
v-if="modals.editingProduct.value"
:product="modals.editingProduct.value"
@close="modals.closeEditModal"
@saved="data.refetch"
/>
<DeleteConfirmDialog
v-if="modals.isDeleteDialogOpen.value"
:count="selection.selectedCount.value"
@confirm="handleDeleteSelected"
@cancel="modals.closeDeleteDialog"
/>
</template>
The component now reads like documentation. Looking at the template tells you the entire structure of the feature — sidebar, toolbar, grid, pagination, three modals. Looking at the script tells you the four composables that power it and the one function that orchestrates a multi-step operation.
The total line count of the parent component: 65 lines.
The Principle Behind the Pattern
Each composable owns a specific domain:
useProductFilters owns: filter state, URL sync, debouncing
useProducts owns: API calls, loading/error state, data
useProductSelection owns: selected IDs, selection operations
useProductModals owns: which modals are open and with what data
None of them know about each other. useProductFilters doesn’t know that useProducts is going to consume its state as arguments. useProductSelection doesn’t know about the URL. useProductModals doesn’t know about the API.
This isolation is what makes each piece independently testable:
// Testing the filters composable — no API mocking needed
import { useProductFilters } from '@/composables/useProductFilters'
test('resets all filters', () => {
const filters = useProductFilters()
filters.searchInput.value = 'sneakers'
filters.selectedCategory.value = 'footwear'
filters.inStockOnly.value = true
filters.reset()
expect(filters.searchInput.value).toBe('')
expect(filters.selectedCategory.value).toBeNull()
expect(filters.inStockOnly.value).toBe(false)
})
// Testing selection — no router, no API, no Vue component
test('selectAll marks all products selected', () => {
const products = ref([{ id: 1 }, { id: 2 }, { id: 3 }])
const selection = useProductSelection(products)
selection.selectAll()
expect(selection.isAllSelected.value).toBe(true)
expect(selection.selectedCount.value).toBe(3)
})
When to Split a Child Component vs a Composable
The decision between extracting to a composable vs a child component comes down to one question: does it render anything?
Does the extracted logic render markup?
├── Yes → Extract to a child component
│ (ProductGrid, ProductFilterSidebar, ProductPagination)
└── No → Extract to a composable
(useProductFilters, useProducts, useProductSelection)
Some guidance on child component boundaries:
Extract to a child component when:
✓ It has its own distinct visual identity (a card, a form, a toolbar)
✓ It could theoretically be used in a different context
✓ Its internal DOM structure would clutter the parent template
✓ It has its own local state that doesn't need to be in the parent
✓ It emits events rather than directly mutating parent state
Keep in the parent when:
✓ It's just a few lines of markup with no logic
✓ The markup is too context-specific to be reusable
✓ Extracting it would require too many props (> 5 is a smell)
The “Too Many Props” Warning Sign
If extracting a component would require passing 7+ props, that’s a signal one of two things is wrong:
- The component is doing too much and should be split further
- The component should use provide/inject or a composable instead of prop drilling
<!-- ✗ Too many props — this component has too many concerns -->
<ProductCard
:product="product"
:is-selected="isSelected"
:is-loading="isLoading"
:current-user="user"
:can-edit="canEdit"
:can-delete="canDelete"
:show-stock-badge="showStockBadge"
:show-quick-view="showQuickView"
:show-bulk-select="showBulkSelect"
@toggle-selection="toggleSelection"
@quick-view="openQuickView"
@edit="openEditModal"
@delete="openDeleteDialog"
/>
<!-- ✓ Focused concerns — card only knows about its product -->
<ProductCard
:product="product"
:is-selected="isSelected"
@toggle-selection="selection.toggle(product.id)"
@action="handleCardAction"
/>
The permissions (canEdit, canDelete), UI configuration flags, and contextual state should live in composables that the child consumes directly — not be threaded down through props.
The File Structure That Reflects the Architecture
src/
├── pages/
│ └── products/
│ └── index.vue ← the slim orchestrator (~65 lines)
│
├── components/
│ └── products/
│ ├── ProductGrid.vue ← renders the product grid
│ ├── ProductCard.vue ← individual product card
│ ├── ProductFilterSidebar.vue
│ ├── ProductListToolbar.vue
│ ├── ProductPagination.vue
│ ├── ProductQuickView.vue
│ ├── ProductEditModal.vue
│ └── DeleteConfirmDialog.vue
│
└── composables/
└── products/
├── useProducts.ts ← data fetching
├── useProductFilters.ts ← filter + URL state
├── useProductSelection.ts ← selection logic
└── useProductModals.ts ← modal open/close state
The directory structure is documentation. A new developer can look at composables/products/ and understand the four domains of state that power the products feature. They can look at components/products/ and understand the eight visual pieces. They don’t need to read 847 lines of a single file to understand the architecture.
The Refactoring Strategy: How to Decompose Without Breaking
If you have an existing god component that needs to be decomposed, the safest approach is incremental:
Week 1: Extract one composable Start with the most independent piece — usually the data fetching. Extract useProducts while keeping everything else in the parent. Verify tests still pass. The component is already better.
Week 2: Extract the next composable Filter state is usually next. It’s self-contained and the change is low-risk.
Week 3: Extract child components Start with the most obviously distinct visual piece — the modal, the sidebar, the pagination. Each extraction reduces the template complexity.
Week 4+: Repeat Keep going until the parent component is an orchestrator.
The key rule: never do a “big bang” refactor of the entire component at once. Extract one piece, verify it works, commit, move on. This keeps the application working throughout the refactor and makes it easy to revert if something breaks.
Final Thoughts
The god component is not a failure of skill. It’s a failure of timing — decisions that were deferred until the component accumulated too much responsibility to decompose easily.
The pattern described here — composables for logic domains, child components for rendering concerns, and a slim parent orchestrator — is not complex. It’s actually simpler than the god component, because each piece is smaller and more focused. The complexity of the feature is distributed across files, each of which is independently understandable.
The signal to act: when you hesitate before opening a component file because you know it’s going to take ten minutes just to orient yourself — that hesitation is the cost of deferred decomposition. The question is whether to pay that cost today or let it compound for another month.
Decompose early. Extract the composable before the component gets to 200 lines, not after it reaches 800. The refactor that takes an afternoon at 200 lines takes a week at 800.
