Composing Components

How to split one screen into components that each do one job, where to draw the line, and who owns which piece of state.

Syntax<line-item-row :label="line.label" @change="setQuantity(line.id, $event)" />

One screen is rarely one component. You split it so each file has a single job, then compose those files into the page. The result is code you can read in one screenful and test one piece at a time.

Where to draw the line

Split when a piece repeats, when it has state of its own, when it is long enough that the surrounding file stops being readable, or when it needs a second use elsewhere. Do not split only to make a file shorter: two components that always change together are really one component, and the split just adds props to keep in step.

Who owns what

Keep each piece of state in the lowest component that needs all of it. Data goes down as props; news comes up as events. A list page owns the list, a row renders what it is handed and emits when the user acts. A row written that way can be tested with no page around it, and the page can change how it stores items without touching the row.

The shape of a page

A typical page is one container that holds the data and a handful of presentational components that only take props and emit. Keep formatting and calculation in plain modules you import, not in components, so they are testable on their own.

What trips people up

v-for needs a :key that identifies the item, such as a database id. The loop index changes when the list reorders, which makes Vue reuse the wrong DOM. And v-if on the same element as v-for cannot see the loop variable, because v-if is evaluated first; put it on a wrapping <template> or filter the list in a computed.

Example

Example · vue
<!-- src/views/OrderPage.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import LineItemRow from '../components/LineItemRow.vue'
import OrderTotals from '../components/OrderTotals.vue'

interface Line {
  id: string
  label: string
  priceCents: number
  quantity: number
}

const lines = ref<Line[]>([
  { id: 'kbd', label: 'Keyboard', priceCents: 4200, quantity: 1 },
  { id: 'cbl', label: 'Cable', priceCents: 900, quantity: 2 }
])

const totalCents = computed(() =>
  lines.value.reduce((sum, line) => sum + line.priceCents * line.quantity, 0)
)

function setQuantity(id: string, quantity: number): void {
  const line = lines.value.find((item) => item.id === id)
  if (line) line.quantity = quantity
}
</script>

<template>
  <main>
    <h3>Order</h3>
    <line-item-row
      v-for="line in lines"
      :key="line.id"
      :label="line.label"
      :price-cents="line.priceCents"
      :quantity="line.quantity"
      @change="setQuantity(line.id, $event)"
    />
    <order-totals :total-cents="totalCents" />
  </main>
</template>

<style scoped>
main {
  max-width: 32rem;
}
</style>

When to use it

  • Turn a 400-line page component into a container plus three presentational children.
  • Decide whether a filter bar should own its filter state or report it to the page.
  • Make a table row testable on its own by giving it props and events instead of a store.

More examples

The row: renders and reports, owns nothing

The row holds no state. It is handed three props, it emits change with the quantity it would like, and the page decides whether that happens. Because it owns nothing, you can mount it with any three props in a test and assert what it emits. Note the derived line total: computation about the row belongs in the row, while the array of lines belongs to the page.

Example · vue
<!-- src/components/LineItemRow.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { formatCents } from '../lib/money'

const props = defineProps<{
  label: string
  priceCents: number
  quantity: number
}>()

const emit = defineEmits<{
  change: [quantity: number]
}>()

const lineTotal = computed(() =>
  formatCents(props.priceCents * props.quantity)
)
</script>

<template>
  <div>
    <span>{{ label }}</span>
    <button type="button" @click="emit('change', quantity - 1)">-</button>
    <span>{{ quantity }}</span>
    <button type="button" @click="emit('change', quantity + 1)">+</button>
    <strong>{{ lineTotal }}</strong>
  </div>
</template>

<style scoped>
div {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}
</style>

The totals strip: purely presentational

A component with props in and markup out, and no events at all. Components like this are worth extracting even when they appear once, because they keep the page file about data and leave the arithmetic of presentation somewhere small. An import used in the template, such as formatCents here, is available to the template directly in <script setup>.

Example · vue
<!-- src/components/OrderTotals.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { formatCents } from '../lib/money'

const props = defineProps<{
  totalCents: number
  shippingCents?: number
}>()

const shipping = computed(() => props.shippingCents ?? 0)
const grandTotal = computed(() =>
  formatCents(props.totalCents + shipping.value)
)
</script>

<template>
  <footer>
    <p>Items: {{ formatCents(totalCents) }}</p>
    <p v-if="shipping > 0">Shipping: {{ formatCents(shipping) }}</p>
    <p><strong>Total: {{ grandTotal }}</strong></p>
  </footer>
</template>

<style scoped>
footer {
  border-top: 1px solid #d0d0d0;
  margin-top: 1rem;
  padding-top: 0.5rem;
}
</style>

Formatting lives in a module, not a component

Where to draw the line in the other direction: this is not interface, so it is not a component. A plain exported function can be imported by the row, by the totals strip and by a test, with no component to mount. Pull anything that is only input to output, such as formatting, validation or sorting, out of your components and into src/lib.

Example · ts
// src/lib/money.ts
export function formatCents(cents: number, currency = 'USD'): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency
  }).format(cents / 100)
}

console.log(formatCents(4200))
// Output: $42.00

console.log(formatCents(900))
// Output: $9.00

console.log(formatCents(4200 + 900 * 2))
// Output: $60.00

Discussion

  • Be the first to comment on this lesson.