Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 073 / 100

Vue design patterns: Vue & Nuxt best practices explained

Patterns that keep code clean.

Expona Admin9 min read

Introduction

After 73 days of Vue and Nuxt, you've likely built components that work, but does your codebase stay maintainable as features grow? Vue design patterns: Vue & Nuxt best practices explained aren't about following dogma—they're about structuring code so your team ships faster and refactors with confidence. Whether you're managing state across a trading dashboard or orchestrating data flows in a SaaS platform, the patterns we'll cover here have proven themselves in production at scale.

Container and Presentational: Separate Data Loading from Display

The container and presentational split is perhaps the clearest way to reduce cognitive load. A container component handles data fetching, API calls, state management, and business logic. A presentational component receives props and emits events—it knows nothing about where data comes from.

Why does this matter? When a designer wants to tweak the UI or a backend integration changes, you modify only the presentational layer. Bugs in data logic stay isolated to the container. Testing becomes straightforward: mock props and assert the UI, or mock API calls and assert the container's state updates.

Real-world example: Ride-hailing trip list

Imagine a trip history page in a ride-hailing app. The container fetches trips, filters them, and handles pagination. The presentational component simply renders a list and emits a "load more" event.

typescript
// TripListContainer.vue
<template>
  <TripListPresentation
    :trips="trips"
    :loading="loading"
    :has-more="hasMore"
    @load-more="fetchMoreTrips"
  />
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import TripListPresentation from './TripListPresentation.vue'

interface Trip {
  id: string
  destination: string
  fare: number
  date: string
}

const trips = ref<Trip[]>([])
const loading = ref(false)
const hasMore = ref(true)
let offset = 0

const fetchMoreTrips = async () => {
  loading.value = true
  try {
    const response = await fetch(`/api/trips?offset=${offset}`)
    const newTrips = await response.json()
    trips.value.push(...newTrips)
    hasMore.value = newTrips.length === 20
    offset += 20
  } finally {
    loading.value = false
  }
}

onMounted(fetchMoreTrips)
</script>
typescript
// TripListPresentation.vue
<template>
  <div class="trip-list">
    <div v-if="loading" class="spinner">Loading...</div>
    <ul v-else>
      <li v-for="trip in trips" :key="trip.id">
        <strong>{{ trip.destination }}</strong> — ${{ trip.fare }}
        <em>{{ trip.date }}</em>
      </li>
    </ul>
    <button
      v-if="hasMore"
      @click="$emit('load-more')"
      :disabled="loading"
    >
      Load More
    </button>
  </div>
</template>

<script setup lang="ts">
defineProps<{
  trips: Array<{ id: string; destination: string; fare: number; date: string }>
  loading: boolean
  hasMore: boolean
}>()

defineEmits<{
  'load-more': []
}>()
</script>

The container owns the API contract and retry logic. The presentation is a pure function of props. When your backend adds a new filter, you update the container's fetch logic—the presentation doesn't change.

Composables for Logic: Reuse Behavior Without Mixins

Mixins were convenient but led to implicit dependencies and naming collisions. Composables are explicit, composable, and tree-shakeable. They're functions that encapsulate reactive state and behavior, returning what components need.

In a CRM system, you might need form validation, debounced search, or permission checks across multiple features. A composable extracts this logic once and reuses it cleanly.

Example: useFormValidation composable

typescript
// composables/useFormValidation.ts
import { ref, computed } from 'vue'

export interface ValidationRule {
  validate: (value: string) => boolean
  message: string
}

export function useFormValidation() {
  const values = ref<Record<string, string>>({})
  const errors = ref<Record<string, string>>({})
  const touched = ref<Record<string, boolean>>({})

  const setFieldValue = (field: string, value: string) => {
    values.value[field] = value
  }

  const validateField = (
    field: string,
    value: string,
    rules: ValidationRule[]
  ) => {
    const failedRule = rules.find((rule) => !rule.validate(value))
    if (failedRule) {
      errors.value[field] = failedRule.message
      return false
    }
    delete errors.value[field]
    return true
  }

  const validateAll = (
    schema: Record<string, ValidationRule[]>
  ): boolean => {
    let isValid = true
    Object.entries(schema).forEach(([field, rules]) => {
      const valid = validateField(field, values.value[field] || '', rules)
      isValid = isValid && valid
    })
    return isValid
  }

  const touchField = (field: string) => {
    touched.value[field] = true
  }

  const isFormDirty = computed(() =>
    Object.values(touched.value).some((t) => t)
  )

  return {
    values,
    errors,
    touched,
    setFieldValue,
    validateField,
    validateAll,
    touchField,
    isFormDirty,
  }
}

Now any component can use this composable and skip the plumbing:

typescript
// components/ContactForm.vue
<script setup lang="ts">
import { useFormValidation } from '@/composables/useFormValidation'

const { values, errors, setFieldValue, validateAll } = useFormValidation()

const emailRules = [
  {
    validate: (v: string) => /.+@.+\..+/.test(v),
    message: 'Invalid email',
  },
]

const submit = () => {
  if (validateAll({ email: emailRules })) {
    console.log('Form valid, submit:', values.value)
  }
}
</script>

Composables scale cleanly across features without polluting the global namespace or creating dependency chains like mixins do.

Renderless Components: Logic With Slots for Flexible UI

A renderless component provides behavior and state through scoped slots, letting the consumer define the markup entirely. This is powerful when the same logic needs different UIs across contexts—a modal in desktop but a drawer on mobile, or a table that can render as a grid.

Example: useAsync renderless component

In a travel booking app, you fetch hotel search results. The desktop shows a grid; mobile shows a list. A renderless component handles both.

typescript
// AsyncLoader.vue (renderless)
<template>
  <slot
    :data="data"
    :loading="loading"
    :error="error"
    :retry="retry"
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'

interface Props {
  fetch: () => Promise<unknown>
}

const props = defineProps<Props>()

const data = ref(null)
const loading = ref(false)
const error = ref<string | null>(null)

const load = async () => {
  loading.value = true
  error.value = null
  try {
    data.value = await props.fetch()
  } catch (err) {
    error.value = err instanceof Error ? err.message : 'Unknown error'
  } finally {
    loading.value = false
  }
}

const retry = () => load()

defineExpose({ load })

onMounted(load)
</script>

Desktop and mobile consumers use the same component but render differently:

typescript
// HotelSearchDesktop.vue
<template>
  <AsyncLoader :fetch="fetchHotels">
    <template #default="{ data, loading, error, retry }">
      <div v-if="loading">Searching...</div>
      <div v-else-if="error" class="error">
        {{ error }}
        <button @click="retry">Retry</button>
      </div>
      <div v-else class="grid">
        <div v-for="hotel in data" :key="hotel.id" class="grid-item">
          {{ hotel.name }}
        </div>
      </div>
    </template>
  </AsyncLoader>
</template>

The renderless component owns the async state machine; each UI layer owns its markup. This separation makes testing and reuse frictionless.

Stores for Shared State: Pinia for Cross-Feature Data

When features need to share state—user authentication, feature flags, cart contents—a store becomes essential. Pinia is Vue's official state management library, and it's simpler and more intuitive than Vuex.

In a SaaS dashboard with real-time updates, multiple features might need to react to a user role change or workspace switch. Pinia stores make this declarative and reactive.

Example: useUserStore in a SaaS app

typescript
// stores/userStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export interface User {
  id: string
  email: string
  role: 'admin' | 'editor' | 'viewer'
  workspaceId: string
}

export const useUserStore = defineStore('user', () => {
  const currentUser = ref<User | null>(null)

  const isAdmin = computed(() => currentUser.value?.role === 'admin')
  const isEditor = computed(
    () => currentUser.value?.role === 'admin' || currentUser.value?.role === 'editor'
  )

  const setUser = (user: User | null) => {
    currentUser.value = user
  }

  const logout = () => {
    currentUser.value = null
  }

  return { currentUser, isAdmin, isEditor, setUser, logout }
})

Components use the store and react automatically to changes:

typescript
// components/FeatureAccess.vue
<script setup lang="ts">
import { useUserStore } from '@/stores/userStore'

const userStore = useUserStore()
</script>

<template>
  <div>
    <button v-if="userStore.isAdmin">Delete User</button>
    <button v-else-if="userStore.isEditor">Edit</button>
    <p v-else>View only</p>
  </div>
</template>

Pinia's composition API style (used above) is more natural than Vuex for teams already writing composables. State, getters, and actions are just functions and refs.

Common Mistakes

  • Mixing container and presentation in one component: It feels faster initially, but when you need to reuse the UI or change the data source, you're stuck refactoring. Split early.
  • Over-composing composables: A composable should do one thing well. If your composable composes five others and has ten lines of logic, split it.
  • Abusing slots in renderless components: Renderless components shine for reusable behavior, but if the slot content is trivial or the logic is trivial, a simpler component or composable might be clearer.
  • Dumping all state into Pinia: Use stores for truly shared state (auth, workspace config). Keep component-local state in refs. Stores should be lean and intentional.
  • Forgetting to type store actions: Pinia with TypeScript is powerful. Omitting types on actions defeats half the benefit; always define input and return types.

Key Takeaways

  • Container and presentational components separate data concerns from UI, making both easier to test and reuse.
  • Composables replace mixins and are easier to reason about, compose, and test in isolation.
  • Renderless components provide behavior through slots, allowing different UIs to share the same state machine.
  • Pinia stores centralize shared state and make reactive updates predictable and debuggable across features.
  • The best pattern for your team is one that reduces merge conflicts, makes testing obvious, and lets junior developers follow a clear path.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles