Vue.js course ยท Module 9: Composables & VueUse
Composables in Production
In this lesson5
The last stage before deploying NOVA LAB systems on Mars - let's learn proven patterns used in production Vue 3 applications. A prototype with three composables in one file works great as long as you work alone. In production there are dozens of composables, several people write them, and every type error only surfaces on the user's screen. So you need order in the files, types and flexible configuration.
Organizing Composable Files
In large projects a clear file structure is essential. Most often you will see a composables folder in which every composable has its own file named after the function:
1src/
2 composables/
3 useAuth.ts # Authorization
4 useNotifications.ts # Notifications
5 useSensors.ts # Sensor data
6 useTheme.ts # App theme
7 index.ts # Re-export of all composablesThe index.ts file is a so-called barrel file: it contains no logic, it only re-exports the functions from the other files, so a component can import everything from one place:
1// composables/index.ts - central export point
2export { useAuth } from './useAuth'
3export { useNotifications } from './useNotifications'
4export { useSensors } from './useSensors'
5export { useTheme } from './useTheme'
6
7// Import in component - clean and readable
8import { useAuth, useNotifications } from '@/composables'The @ alias points to the src folder - projects created with create-vue have it configured in Vite and in TypeScript. A barrel file has its price, though: the Vite performance guide recommends avoiding such files, because importing one function forces the dev server to fetch and transform all the re-exported modules. In a small folder it doesn't matter; in a large one it's better to import directly, for example from '@/composables/useAuth'.
Typing Composables with TypeScript
TypeScript provides type safety and better IDE hints. Vue exports the Ref<T> type for refs and ComputedRef<T> for computed values. We import them with the type keyword, because create-vue templates enable the verbatimModuleSyntax option, which requires type-only imports to be marked. The <T> parameter makes the composable generic, so it works with a list of any items:
1import { ref, computed, type Ref, type ComputedRef } from 'vue'
2
3// Interface for returned API
4interface UsePaginationReturn<T> {
5 currentPage: Ref<number>
6 totalPages: ComputedRef<number>
7 paginatedItems: ComputedRef<T[]>
8 goToPage: (page: number) => void
9 nextPage: () => void
10 prevPage: () => void
11}
12
13// Typed composable
14function usePagination<T>(
15 items: Ref<T[]>,
16 perPage: number = 10
17): UsePaginationReturn<T> {
18 const currentPage = ref(1)
19
20 const totalPages = computed(() =>
21 Math.ceil(items.value.length / perPage)
22 )
23
24 const paginatedItems = computed(() => {
25 const start = (currentPage.value - 1) * perPage
26 return items.value.slice(start, start + perPage)
27 })
28
29 function goToPage(page: number) {
30 if (page >= 1 && page <= totalPages.value) {
31 currentPage.value = page
32 }
33 }
34
35 function nextPage() { goToPage(currentPage.value + 1) }
36 function prevPage() { goToPage(currentPage.value - 1) }
37
38 return {
39 currentPage,
40 totalPages,
41 paginatedItems,
42 goToPage,
43 nextPage,
44 prevPage
45 }
46}When you pass a Ref<Experiment[]>, paginatedItems gets the type ComputedRef<Experiment[]> and the IDE suggests the experiment's fields. An explicit interface for the returned API is not mandatory, because TypeScript infers the type by itself, but it documents the composable's public contract.
Composable with Configuration Options
The options pattern allows flexible composable configuration - like the configuration panels of NOVA LAB modules. Instead of five parameters in a fixed order, the composable takes one object whose fields are all optional:
1import { ref, onUnmounted } from 'vue'
2
3interface UsePollingOptions {
4 interval?: number
5 immediate?: boolean
6 retryOnError?: boolean
7 maxRetries?: number
8 onError?: (error: Error) => void
9}The question mark marks an optional field, and onError is a function the user can provide to find out about an error. We set the default values in the composable itself, while destructuring the options:
1function usePolling<T>(
2 fetchFn: () => Promise<T>,
3 options: UsePollingOptions = {}
4) {
5 const {
6 interval = 5000,
7 immediate = true,
8 retryOnError = true,
9 maxRetries = 3,
10 onError
11 } = options
12
13 const data = ref<T | null>(null)
14 const error = ref<Error | null>(null)
15 const isPolling = ref(false)
16 const retryCount = ref(0)
17 let timerId: ReturnType<typeof setInterval> | null = null
18
19 async function poll() {
20 try {
21 data.value = await fetchFn()
22 error.value = null
23 retryCount.value = 0
24 } catch (e) {
25 // catch receives unknown - we turn it into an Error
26 const err = e instanceof Error ? e : new Error(String(e))
27 error.value = err
28 onError?.(err)
29
30 if (!retryOnError || retryCount.value >= maxRetries) {
31 stopPolling()
32 return
33 }
34 retryCount.value++
35 }
36 }
37
38 function startPolling() {
39 if (isPolling.value) return
40 isPolling.value = true
41 if (immediate) poll()
42 timerId = setInterval(poll, interval)
43 }
44
45 function stopPolling() {
46 isPolling.value = false
47 if (timerId) {
48 clearInterval(timerId)
49 timerId = null
50 }
51 }
52
53 onUnmounted(stopPolling)
54
55 return { data, error, isPolling, retryCount, startPolling, stopPolling }
56}Three details set this code apart from a prototype. In strict mode the catch variable has the type unknown, so we turn it into an Error before passing it to onError. The ReturnType<typeof setInterval> type works both in the browser, where setInterval returns a number, and with Node.js types, where it returns an object. And onError?.(err) calls the function only if someone provided it. After an error, the next tick of the interval is a retry, up to maxRetries. This is how it looks with custom options:
1// Usage with options
2const sensorData = usePolling(
3 () => fetch('/api/sensors').then(r => r.json()),
4 {
5 interval: 3000,
6 immediate: true,
7 maxRetries: 5,
8 onError: (e) => console.error('Polling failed:', e)
9 }
10)The omitted retryOnError takes its default value of true. The order of fields doesn't matter, and adding a new option in the future won't break existing calls.
Composable with provide/inject for Large Applications
In production applications we often need shared state without prop drilling, that is without passing props through several levels of components. So that inject knows the type of the provided value, Vue offers InjectionKey<T> - a symbol that carries type information. First we describe the data and the key:
1import { ref, computed, provide, inject, readonly, type InjectionKey } from 'vue'
2
3interface User {
4 id: string
5 name: string
6}
7
8interface AppNotification {
9 id: number
10 message: string
11 type: string
12 timestamp: Date
13}
14
15// We derive the store type from the function that creates it
16type AppStore = ReturnType<typeof createAppStore>
17const StoreKey: InjectionKey<AppStore> = Symbol('AppStore')The name AppNotification instead of Notification is no accident - Notification is a built-in browser type. ReturnType makes sure the store type always matches what the function below really returns:
1// Created once in the root component (App.vue)
2function createAppStore() {
3 const user = ref<User | null>(null)
4 const isAuthenticated = computed(() => user.value !== null)
5 const notifications = ref<AppNotification[]>([])
6
7 function login(userData: User) { user.value = userData }
8 function logout() { user.value = null }
9 function notify(message: string, type = 'info') {
10 notifications.value.push({
11 id: Date.now(),
12 message,
13 type,
14 timestamp: new Date()
15 })
16 }
17
18 const store = {
19 user: readonly(user),
20 isAuthenticated,
21 notifications: readonly(notifications),
22 login,
23 logout,
24 notify
25 }
26
27 provide(StoreKey, store)
28 return store
29}Components receive user and notifications as read-only, so only the store's methods change the state. What remains is the function for consumers:
1// Used in any nested component
2function useAppStore() {
3 const store = inject(StoreKey)
4 if (!store) {
5 throw new Error('useAppStore requires createAppStore in App.vue')
6 }
7 return store
8}Thanks to InjectionKey, the result of useAppStore() is fully typed and the IDE suggests login, notify or user. Without a typed key, inject would return unknown.
Module Summary
In this module you learned advanced techniques for working with composables:
- Factory composables - creating multiple composable instances with different configurations
- Composition - combining composables into larger systems, a composable using another composable
- Dependency Injection -
provide/injectfor shared state without prop drilling - Reactive patterns - Singleton, Event Bus, State Machine, Observer - all with Vue reactivity
- VueUse - 200+ ready-made composables (
useLocalStorage,useDark,useWindowSize) - Testing - unit testing composables with Vitest, mocking,
nextTickfor watchers, testing async operations - Optimization -
shallowReffor large data, computed caching, avoiding memory leaks and cancelling requests - Production - TypeScript typing, file organization, configuration options pattern
These techniques will let you build scalable and efficient Vue 3 applications - ready for space missions!
My advice: when a composable has more than two configuration parameters, switch to an options object with default values, and describe the public API with types from the first version. You will deepen typing in Vue at the Quantum Laboratory, and Pinia will take over global state in the Mission Database. In the editor below, the app store works through provide/inject, and the sensor polling uses configuration options.
Remember: a production-ready composable is a station module with a user manual - it has its place in the warehouse, labeled connectors and sensible factory settings.
Code for this lesson: App.vue
1<script setup>
2import { ref, computed, provide, inject, readonly, onUnmounted } from 'vue'
3
4// ===== PRODUCTION COMPOSABLES DEMO =====
5
6// 1. TypeScript-style typed composable with options
7const StoreKey = Symbol('AppStore')
8
9function createAppStore() {
10 const user = ref({ name: 'Dr. Nova', role: 'commander' })
11 const isAuthenticated = computed(() => user.value !== null)
12 const notifications = ref([])
13
14 function notify(message, type = 'info') {
15 notifications.value.unshift({ id: Date.now(), message, type, timestamp: new Date().toLocaleTimeString() })
16 if (notifications.value.length > 10) notifications.value.pop()
17 }
18
19 function logout() { user.value = null }
20
21 const store = { user: readonly(user), isAuthenticated, notifications: readonly(notifications), notify, logout }
22 provide(StoreKey, store)
23 return store
24}
25
26function useAppStore() {
27 const store = inject(StoreKey)
28 if (!store) throw new Error('Store not provided')
29 return store
30}
31
32// 2. Polling composable with options pattern
33function usePolling(fetchFn, options = {}) {
34 const { interval = 3000, immediate = true, maxRetries = 3, onError } = options
35 const data = ref(null)
36 const error = ref(null)
37 const isPolling = ref(false)
38 const retryCount = ref(0)
39 const pollCount = ref(0)
40 let timerId = null
41
42 async function poll() {
43 try {
44 data.value = await fetchFn()
45 error.value = null
46 retryCount.value = 0
47 pollCount.value++
48 } catch (e) {
49 error.value = e.message || 'Error'
50 onError?.(e)
51 if (retryCount.value >= maxRetries) { stopPolling(); return }
52 retryCount.value++
53 }
54 }
55
56 function startPolling() {
57 if (isPolling.value) return
58 isPolling.value = true
59 if (immediate) poll()
60 timerId = setInterval(poll, interval)
61 }
62
63 function stopPolling() {
64 isPolling.value = false
65 if (timerId) { clearInterval(timerId); timerId = null }
66 }
67
68 onUnmounted(stopPolling)
69 return { data, error, isPolling, retryCount, pollCount, startPolling, stopPolling }
70}
71
72// === Setup ===
73const store = createAppStore()
74
75// Simulate sensor API
76const sensorPolling = usePolling(
77 async () => {
78 await new Promise(r => setTimeout(r, 500))
79 if (Math.random() < 0.15) throw new Error('Sensor timeout')
80 return {
81 temperature: Math.round(-60 + Math.random() * 80),
82 pressure: Math.round(90 + Math.random() * 30),
83 oxygen: Math.round(85 + Math.random() * 15),
84 timestamp: new Date().toLocaleTimeString()
85 }
86 },
87 {
88 interval: 2000,
89 immediate: true,
90 maxRetries: 5,
91 onError: (e) => store.notify('Sensor error: ' + e.message, 'danger')
92 }
93)
94</script>
95
96<template>
97 <div class="prod-lab">
98 <h1>Composables in Production - NOVA LAB</h1>
99
100 <section>
101 <h2>App Store (provide/inject)</h2>
102 <div v-if="store.isAuthenticated.value" class="user-info">
103 <p>User: {{ store.user.value.name }} ({{ store.user.value.role }})</p>
104 <button @click="store.logout()">Logout</button>
105 </div>
106 <p v-else class="warn">Not authenticated</p>
107 <button @click="store.notify('System check OK', 'info')">Add Info</button>
108 <button @click="store.notify('Low battery', 'warning')">Add Warning</button>
109 <div class="notifs">
110 <div v-for="n in store.notifications.value" :key="n.id" class="notif" :class="n.type">
111 <span class="time">{{ n.timestamp }}</span> {{ n.message }}
112 </div>
113 </div>
114 </section>
115
116 <section>
117 <h2>Polling with Options</h2>
118 <div class="poll-controls">
119 <button @click="sensorPolling.startPolling()" :disabled="sensorPolling.isPolling.value" class="start">Start</button>
120 <button @click="sensorPolling.stopPolling()" :disabled="!sensorPolling.isPolling.value" class="stop">Stop</button>
121 </div>
122 <p class="poll-info">
123 Polling: {{ sensorPolling.isPolling.value ? 'ACTIVE' : 'STOPPED' }} |
124 Polls: {{ sensorPolling.pollCount.value }} |
125 Retries: {{ sensorPolling.retryCount.value }}
126 </p>
127 <div v-if="sensorPolling.data.value" class="sensor-data">
128 <div class="sensor">Temp: {{ sensorPolling.data.value.temperature }}ยฐC</div>
129 <div class="sensor">Pressure: {{ sensorPolling.data.value.pressure }} kPa</div>
130 <div class="sensor">O2: {{ sensorPolling.data.value.oxygen }}%</div>
131 <div class="sensor time">{{ sensorPolling.data.value.timestamp }}</div>
132 </div>
133 <div v-if="sensorPolling.error.value" class="error">{{ sensorPolling.error.value }}</div>
134 </section>
135 </div>
136</template>
137
138<style scoped>
139.prod-lab { background: #0a0e27; color: #e0e0ff; padding: 1.5rem; min-height: 100vh; font-family: 'Courier New', monospace; }
140h1 { color: #00ff88; text-align: center; }
141h2 { color: #00b4d8; border-bottom: 1px solid #00b4d8; padding-bottom: 0.5rem; }
142section { margin: 1.5rem 0; }
143button { background: #00b4d8; color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer; margin: 0.2rem; font-family: inherit; }
144button:hover { background: #00ff88; color: #0a0e27; }
145button:disabled { opacity: 0.4; }
146button.start { background: #00ff88; color: #0a0e27; }
147button.stop { background: #ff0055; }
148.user-info { background: rgba(0,255,136,0.1); border: 1px solid #00ff88; border-radius: 6px; padding: 0.8rem; margin: 0.5rem 0; }
149.warn { color: #ffb400; }
150.notifs { margin-top: 0.5rem; max-height: 200px; overflow-y: auto; }
151.notif { font-size: 0.8rem; padding: 0.3rem 0.6rem; margin: 0.2rem 0; border-radius: 3px; }
152.notif.info { background: rgba(0,180,216,0.1); border-left: 3px solid #00b4d8; }
153.notif.warning { background: rgba(255,180,0,0.1); border-left: 3px solid #ffb400; }
154.notif.danger { background: rgba(255,0,85,0.1); border-left: 3px solid #ff0055; }
155.time { color: #888; margin-right: 0.5rem; font-size: 0.75rem; }
156.poll-controls { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
157.poll-info { color: #00b4d8; font-size: 0.9rem; }
158.sensor-data { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 0.5rem; margin: 0.5rem 0; }
159.sensor { background: rgba(0,180,216,0.1); border: 1px solid #00b4d8; border-radius: 6px; padding: 0.6rem; text-align: center; font-weight: bold; }
160.sensor.time { color: #888; font-weight: normal; font-size: 0.8rem; }
161.error { background: rgba(255,0,85,0.1); border: 1px solid #ff0055; color: #ff0055; padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; }
162</style>Spotted a mistake in this lesson?