Best practices [VueJS]

A best practices guide for developing performant desktop applications with Vue.js 3 and Tauri.

Component architecture#

Small, focused components#

Bad example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- ❌ Monolithic component -->
<template>
  <div class="control-center">
    <!-- Audio controls -->
    <div class="audio">
      <input v-model="volume" type="range" />
      <button @click="toggleMute">Mute</button>
      <select v-model="selectedDevice">
        <option v-for="device in devices">{{ device }}</option>
      </select>
    </div>
    <!-- Bluetooth controls -->
    <div class="bluetooth">...</div>
    <!-- Network controls -->
    <div class="network">...</div>
    <!-- Battery info -->
    <div class="battery">...</div>
  </div>
</template>

<script setup>
// 300+ lines of mixed logic
const volume = ref(50)
const devices = ref([])
// ... a lot more logic
</script>

Good example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<!-- ✅ Small main component -->
<template>
  <div class="control-center">
    <AudioControl />
    <BluetoothControl />
    <NetworkControl />
    <BatteryInfo />
  </div>
</template>

<script setup lang="ts">
import AudioControl from './components/AudioControl.vue'
import BluetoothControl from './components/BluetoothControl.vue'
import NetworkControl from './components/NetworkControl.vue'
import BatteryInfo from './components/BatteryInfo.vue'
</script>

Typed, documented props#

Bad example:

1
2
3
4
<script setup>
// ❌ Props with no types or documentation
const props = defineProps(['title', 'data', 'callback'])
</script>

Good example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script setup lang="ts">
interface Device {
  id: string
  name: string
  volume: number
}

interface Props {
  /** Title of the audio component */
  title: string
  /** List of available audio devices */
  devices: Device[]
  /** Current volume (0-100) */
  currentVolume: number
  /** Whether the audio is muted */
  muted?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  muted: false
})
</script>

Well-defined events#

Bad example:

1
2
3
4
5
6
7
8
<script setup>
// ❌ Events with no types
const emit = defineEmits(['update', 'change', 'click'])

function handleClick() {
  emit('update', someData) // What shape is someData?
}
</script>

Good example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<script setup lang="ts">
interface Emits {
  /** Emitted when the volume changes */
  (e: 'volume-changed', volume: number): void
  /** Emitted when the device is changed */
  (e: 'device-selected', deviceId: string): void
  /** Emitted when mute is toggled */
  (e: 'mute-toggled', muted: boolean): void
}

const emit = defineEmits<Emits>()

function handleVolumeChange(newVolume: number) {
  emit('volume-changed', newVolume)
}
</script>

Performance and optimisation#

Lazy loading of components#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'

// ✅ Load heavy components only when they are needed
const SettingsDialog = defineAsyncComponent(
  () => import('./components/SettingsDialog.vue')
)

const FileManager = defineAsyncComponent(
  () => import('./components/FileManager.vue')
)
</script>

<template>
  <SettingsDialog v-if="showSettings" />
  <FileManager v-if="showFileManager" />
</template>

Virtual scrolling for large lists#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'

const allApps = ref<App[]>([]) // 1000+ applications

// ✅ Render only the visible items
const { list, containerProps, wrapperProps } = useVirtualList(
  allApps,
  {
    itemHeight: 50,
    overscan: 5
  }
)
</script>

<template>
  <div v-bind="containerProps" class="app-list">
    <div v-bind="wrapperProps">
      <AppItem
        v-for="{ data, index } in list"
        :key="data.id"
        :app="data"
      />
    </div>
  </div>
</template>

Debounce on searches#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { invoke } from '@tauri-apps/api/tauri'

const searchQuery = ref('')
const results = ref<SearchResult[]>([])

// ✅ Debounce to avoid excessive calls to the backend
const debouncedSearch = useDebounceFn(async (query: string) => {
  if (!query.trim()) {
    results.value = []
    return
  }

  try {
    results.value = await invoke<SearchResult[]>('global_search', {
      query
    })
  } catch (error) {
    console.error('Search failed:', error)
  }
}, 300)

watch(searchQuery, (newQuery) => {
  debouncedSearch(newQuery)
})
</script>

Memoising expensive computeds#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script setup lang="ts">
import { computed } from 'vue'

const apps = ref<App[]>([])
const searchQuery = ref('')
const selectedCategory = ref('all')

// ✅ Memoised computed - only recalculated when the dependencies change
const filteredApps = computed(() => {
  return apps.value.filter(app => {
    const matchesSearch = app.name
      .toLowerCase()
      .includes(searchQuery.value.toLowerCase())

    const matchesCategory = selectedCategory.value === 'all' ||
                           app.category === selectedCategory.value

    return matchesSearch && matchesCategory
  })
})

// ❌ Avoid recalculating on every render
// const getFilteredApps = () => apps.value.filter(...)
</script>

v-show vs v-if#

1
2
3
4
5
6
7
<template>
  <!-- ✅ v-show for components that toggle frequently -->
  <AudioApplet v-show="showAudioApplet" />

  <!-- ✅ v-if for components that are rarely shown -->
  <SettingsDialog v-if="showSettings" />
</template>

State management#

Use Pinia for global state#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// stores/audio.ts
import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/tauri'
import { listen } from '@tauri-apps/api/event'

export const useAudioStore = defineStore('audio', () => {
  const volume = ref(50)
  const muted = ref(false)
  const devices = ref<AudioDevice[]>([])
  const selectedDevice = ref<string | null>(null)

  // ✅ Clearly defined actions
  async function setVolume(newVolume: number) {
    try {
      await invoke('set_audio_volume', { volume: newVolume })
      volume.value = newVolume
    } catch (error) {
      console.error('Failed to set volume:', error)
      throw error
    }
  }

  async function toggleMute() {
    try {
      await invoke('toggle_audio_mute')
      muted.value = !muted.value
    } catch (error) {
      console.error('Failed to toggle mute:', error)
      throw error
    }
  }

  // ✅ Listen to events from the backend
  function initializeListeners() {
    listen<number>('audio_volume_changed', (event) => {
      volume.value = event.payload
    })

    listen<boolean>('audio_mute_changed', (event) => {
      muted.value = event.payload
    })
  }

  return {
    volume,
    muted,
    devices,
    selectedDevice,
    setVolume,
    toggleMute,
    initializeListeners
  }
})

Composables for reusable logic#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// composables/useBackendCommand.ts
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/tauri'

export function useBackendCommand<T, P = void>(
  command: string
) {
  const loading = ref(false)
  const error = ref<string | null>(null)
  const data = ref<T | null>(null)

  async function execute(params?: P): Promise<T | null> {
    loading.value = true
    error.value = null

    try {
      const result = await invoke<T>(command, params)
      data.value = result
      return result
    } catch (err) {
      error.value = err as string
      console.error(`Command ${command} failed:`, err)
      return null
    } finally {
      loading.value = false
    }
  }

  return {
    loading: readonly(loading),
    error: readonly(error),
    data: readonly(data),
    execute
  }
}

// Usage in a component
const { loading, error, data, execute } = useBackendCommand<SystemInfo>(
  'get_system_info'
)

onMounted(() => {
  execute()
})

Communicating with the backend#

Robust handling of Tauri commands#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<script setup lang="ts">
import { invoke } from '@tauri-apps/api/tauri'

const brightness = ref(50)
const isUpdating = ref(false)
const updateError = ref<string | null>(null)

// ✅ Complete async handling with loading and errors
async function updateBrightness(newValue: number) {
  isUpdating.value = true
  updateError.value = null

  try {
    await invoke('set_brightness_info', { brightness: newValue })
    brightness.value = newValue
  } catch (error) {
    updateError.value = 'Failed to update brightness'
    console.error('Brightness update failed:', error)

    // Revert to the previous value
    // brightness is left unchanged
  } finally {
    isUpdating.value = false
  }
}

// ❌ Avoid this
// async function badUpdate(value: number) {
//   await invoke('set_brightness_info', { brightness: value })
//   brightness.value = value // What if it fails?
// }
</script>

Event listeners with cleanup#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script setup lang="ts">
import { listen, UnlistenFn } from '@tauri-apps/api/event'

const notifications = ref<Notification[]>([])
let unlistenNotification: UnlistenFn | null = null

onMounted(async () => {
  // ✅ Keep the cleanup function
  unlistenNotification = await listen<Notification>(
    'notification_received',
    (event) => {
      notifications.value.push(event.payload)
    }
  )
})

onUnmounted(() => {
  // ✅ Always clean up listeners
  if (unlistenNotification) {
    unlistenNotification()
  }
})
</script>

Timeout for long operations#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async function fetchWithTimeout<T>(
  command: string,
  params: any,
  timeoutMs = 5000
): Promise<T> {
  return Promise.race([
    invoke<T>(command, params),
    new Promise<T>((_, reject) =>
      setTimeout(() => reject(new Error('Operation timeout')), timeoutMs)
    )
  ])
}

// Usage
try {
  const devices = await fetchWithTimeout<Device[]>(
    'scan_bluetooth_devices',
    {},
    10000 // 10 seconds
  )
} catch (error) {
  console.error('Scan timeout or failed:', error)
}

Error handling#

Error boundaries and feedback#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<script setup lang="ts">
import { ref } from 'vue'

const errorMessage = ref<string | null>(null)
const showError = ref(false)

function handleError(error: unknown, context: string) {
  const message = error instanceof Error
    ? error.message
    : String(error)

  console.error(`Error in ${context}:`, error)

  errorMessage.value = message
  showError.value = true

  // Auto-hide after 5 seconds
  setTimeout(() => {
    showError.value = false
  }, 5000)
}

async function loadData() {
  try {
    await invoke('load_data')
  } catch (error) {
    handleError(error, 'loadData')
  }
}
</script>

<template>
  <div class="error-toast" v-if="showError">
    {{ errorMessage }}
  </div>
</template>

Memory management#

Complete cleanup#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<script setup lang="ts">
import { onUnmounted } from 'vue'

const intervalId = ref<number | null>(null)
const observers = ref<ResizeObserver[]>([])
const unlisteners = ref<UnlistenFn[]>([])

onMounted(() => {
  // Interval to update the data
  intervalId.value = setInterval(updateSystemInfo, 2000)

  // Observer for resize
  const observer = new ResizeObserver(handleResize)
  observer.observe(element.value!)
  observers.value.push(observer)

  // Event listeners
  setupEventListeners()
})

onUnmounted(() => {
  // ✅ Clear the interval
  if (intervalId.value) {
    clearInterval(intervalId.value)
  }

  // ✅ Disconnect the observers
  observers.value.forEach(obs => obs.disconnect())
  observers.value = []

  // ✅ Clean up the event listeners
  unlisteners.value.forEach(unlisten => unlisten())
  unlisteners.value = []
})
</script>

Preventing memory leaks in watchers#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<script setup lang="ts">
import { watch, WatchStopHandle } from 'vue'

const stopWatchers: WatchStopHandle[] = []

onMounted(() => {
  // ✅ Keep the stop function
  const stopVolumeWatch = watch(volume, async (newVal) => {
    await invoke('set_audio_volume', { volume: newVal })
  })

  stopWatchers.push(stopVolumeWatch)
})

onUnmounted(() => {
  // ✅ Stop all the watchers
  stopWatchers.forEach(stop => stop())
})
</script>

Accessibility#

ARIA labels and keyboard navigation#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<template>
  <div class="volume-control">
    <label for="volume-slider" class="sr-only">
      Volume Control
    </label>

    <input
      id="volume-slider"
      type="range"
      min="0"
      max="100"
      :value="volume"
      @input="handleVolumeChange"
      aria-label="Volume level"
      aria-valuemin="0"
      aria-valuemax="100"
      :aria-valuenow="volume"
      :aria-valuetext="`${volume}%`"
    />

    <button
      @click="toggleMute"
      :aria-label="muted ? 'Unmute' : 'Mute'"
      :aria-pressed="muted"
    >
      <Icon :name="muted ? 'volume-mute' : 'volume'" />
    </button>
  </div>
</template>

<style scoped>
/* ✅ Screen reader only text */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}
</style>

Focus management#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<script setup lang="ts">
import { ref, nextTick } from 'vue'

const showDialog = ref(false)
const firstFocusableElement = ref<HTMLElement | null>(null)
const previousActiveElement = ref<HTMLElement | null>(null)

async function openDialog() {
  previousActiveElement.value = document.activeElement as HTMLElement
  showDialog.value = true

  await nextTick()
  firstFocusableElement.value?.focus()
}

function closeDialog() {
  showDialog.value = false
  previousActiveElement.value?.focus()
}

// ✅ Trap focus inside the dialog
function handleKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape') {
    closeDialog()
  }
}
</script>

Testing#

Unit tests with Vitest#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// AudioControl.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import AudioControl from './AudioControl.vue'

// Mock Tauri
vi.mock('@tauri-apps/api/tauri', () => ({
  invoke: vi.fn()
}))

describe('AudioControl', () => {
  it('renders volume slider', () => {
    const wrapper = mount(AudioControl, {
      props: {
        currentVolume: 50
      }
    })

    expect(wrapper.find('input[type="range"]').exists()).toBe(true)
  })

  it('emits volume-changed event', async () => {
    const wrapper = mount(AudioControl)
    const slider = wrapper.find('input[type="range"]')

    await slider.setValue(75)

    expect(wrapper.emitted('volume-changed')).toBeTruthy()
    expect(wrapper.emitted('volume-changed')?.[0]).toEqual([75])
  })
})

Best practices - summary#

graph LR Practices["✅ Best practices"] Avoid["❌ Avoid"] Practices --> P1["✓ Small, focused components"] Practices --> P2["✓ Typed, documented props"] Practices --> P3["✓ Explicit error handling"] Practices --> P4["✓ Cleanup in onUnmounted"] Practices --> P5["✓ Composables for shared logic"] Practices --> P6["✓ Well-defined events"] Practices --> P7["✓ Lazy loading of components"] Practices --> P8["✓ Virtual scrolling for lists"] Practices --> P9["✓ Debounce on searches"] Practices --> P10["✓ Timeout on long operations"] Avoid --> E1["✗ Monolithic components"] Avoid --> E2["✗ Untyped props"] Avoid --> E3["✗ Ignoring async errors"] Avoid --> E4["✗ Memory leaks from listeners"] Avoid --> E5["✗ Hardcoded code"] Avoid --> E6["✗ Side effects during render"] Avoid --> E7["✗ Rendering 1000+ items without virtual scroll"] Avoid --> E8["✗ Calls without debounce"] Avoid --> E9["✗ Operations without a timeout"] Avoid --> E10["✗ Forgetting to clean up watchers"] style Practices fill:#43e97b,stroke:#38f9d7,color:#fff style Avoid fill:#f093fb,stroke:#f5576c,color:#fff style P1 fill:#4facfe,stroke:#00f2fe,color:#fff style P2 fill:#4facfe,stroke:#00f2fe,color:#fff style P3 fill:#4facfe,stroke:#00f2fe,color:#fff style P4 fill:#4facfe,stroke:#00f2fe,color:#fff style P5 fill:#4facfe,stroke:#00f2fe,color:#fff style P6 fill:#4facfe,stroke:#00f2fe,color:#fff style P7 fill:#4facfe,stroke:#00f2fe,color:#fff style P8 fill:#4facfe,stroke:#00f2fe,color:#fff style P9 fill:#4facfe,stroke:#00f2fe,color:#fff style P10 fill:#4facfe,stroke:#00f2fe,color:#fff style E1 fill:#fa709a,stroke:#f5576c,color:#fff style E2 fill:#fa709a,stroke:#f5576c,color:#fff style E3 fill:#fa709a,stroke:#f5576c,color:#fff style E4 fill:#fa709a,stroke:#f5576c,color:#fff style E5 fill:#fa709a,stroke:#f5576c,color:#fff style E6 fill:#fa709a,stroke:#f5576c,color:#fff style E7 fill:#fa709a,stroke:#f5576c,color:#fff style E8 fill:#fa709a,stroke:#f5576c,color:#fff style E9 fill:#fa709a,stroke:#f5576c,color:#fff style E10 fill:#fa709a,stroke:#f5576c,color:#fff

Component checklist#

  • Create the Vue component 🚀
  • Clear, descriptive name 📝
  • Props documented with types 📋
  • Well-defined events 📡
  • Robust error handling ⚠️
  • Cleanup in onUnmounted 🧹
  • Scoped styles 🎨
  • Unit tests 🧪
  • Accessibility (ARIA) ♿
  • Optimised (lazy load, virtual scroll) ⚡
  • Production ready ✅

Performance checklist specific to desktop#

  • Virtual scrolling implemented on large lists (>100 items)
  • Debounce on searches and high-frequency inputs
  • Lazy loading for heavy components (Settings, File Manager)
  • Memoisation of expensive computeds
  • Timeout on every call to the backend (5-10s)
  • Loading states visible for async operations
  • Error recovery with automatic retries
  • Cleanup of all listeners and watchers
  • v-show for components that toggle frequently
  • v-if for components that are rarely shown

Further reading#

Remember#