Vue.js course ยท Module 9: Composables & VueUse
Advanced Composable Patterns
In this lesson5
Welcome to the NOVA LAB Science Laboratory! You already know the basics of composables from the 3D Printing Workshop - now it's time for advanced design patterns. Let's start with a problem: the station tracks three resources, namely oxygen, fuel and water. Each needs the same logic - the current level, a percentage, an alarm when it runs low, consumption and refilling. Copying that logic into three components means three places to fix with every bug. Just as NOVA LAB engineers design modular space station systems, we will design composables ready for any challenge.
What you will learn
In the Science Laboratory you will master expert-level research protocols:
- creating composable factories and combining composables into larger systems,
- providing shared context with
provideandinject, and handling errors with retries, - building reactive design patterns: singleton, event bus, state machine and observer,
- using ready-made composables from the VueUse library,
- testing composables in Vitest and optimizing their performance,
- preparing composables for production: types, file organization and configuration options.
Factory Composables
A factory composable is a function that creates composables with different configurations - like a station module factory where each module has the same interfaces but different parameters. Its name starts with create rather than use to stress that every call produces a new, independent instance. Here is a factory that takes a resource name and a maximum capacity:
1import { ref, computed } from 'vue'
2
3// Factory: creates a composable for any resource
4function createResourceManager(resourceName, maxCapacity = 100) {
5 const currentLevel = ref(maxCapacity)
6 const isLow = computed(() => currentLevel.value < maxCapacity * 0.2)
7 const isCritical = computed(() => currentLevel.value < maxCapacity * 0.05)
8 const percentage = computed(() =>
9 Math.round((currentLevel.value / maxCapacity) * 100)
10 )
11
12 function consume(amount) {
13 currentLevel.value = Math.max(0, currentLevel.value - amount)
14 }
15
16 function refill(amount) {
17 currentLevel.value = Math.min(maxCapacity, currentLevel.value + amount)
18 }
19
20 return {
21 resourceName,
22 currentLevel,
23 maxCapacity,
24 percentage,
25 isLow,
26 isCritical,
27 consume,
28 refill
29 }
30}The level starts at full capacity, isLow switches on below 20%, and isCritical below 5%. Math.max and Math.min keep the level from dropping below zero or exceeding the capacity. Now let's produce three managers:
1// Usage - each resource has the same methods but independent state
2const oxygen = createResourceManager('Oxygen', 500)
3const fuel = createResourceManager('Fuel', 1000)
4const water = createResourceManager('Water', 200)
5
6oxygen.consume(50) // does not affect fuel or waterAfter this call oxygen has 450 units, which is 90%, while fuel is still a full 1000. Every call of the factory created its own refs, so the resources share nothing.
Composable Composition
Composables can use other composables - like connecting station modules into larger systems. First a base notification composable, which adds new entries to the beginning of the list and keeps at most the 50 most recent ones:
1import { ref, computed, watch } from 'vue'
2
3// Base composable
4function useNotifications() {
5 const notifications = ref([])
6
7 function addNotification(message, type = 'info') {
8 notifications.value.unshift({
9 id: Date.now(),
10 message,
11 type,
12 timestamp: new Date()
13 })
14 // Limit to 50 notifications
15 if (notifications.value.length > 50) {
16 notifications.value.pop()
17 }
18 }
19
20 function clearAll() {
21 notifications.value = []
22 }
23
24 return { notifications, addNotification, clearAll }
25}unshift inserts an element at the start of the array, and pop removes the last, oldest one. The lab monitor calls this composable inside itself and observes the sensors with watch, comparing the readings with the limits from the thresholds parameter:
1// Composable that uses another composable
2function useLabMonitor(thresholds) {
3 const temperature = ref(22)
4 const pressure = ref(101)
5 const { notifications, addNotification, clearAll } = useNotifications()
6
7 watch(temperature, (val) => {
8 if (val > thresholds.maxTemp) {
9 addNotification(
10 `Critical temperature: ${val}ยฐC!`,
11 'danger'
12 )
13 }
14 })
15
16 watch(pressure, (val) => {
17 if (val < thresholds.minPressure) {
18 addNotification(
19 `Pressure below normal: ${val} kPa`,
20 'warning'
21 )
22 }
23 })
24
25 return {
26 temperature,
27 pressure,
28 notifications,
29 addNotification,
30 clearAll
31 }
32}The component sees one composable, even though two are working underneath. Every call of useLabMonitor also gets its own list of notifications - a second monitor starts with an empty list.
Dependency Injection in Composables
You know the provide and inject mechanism from the Communication Station. Wrapped in a pair of composables, it becomes a central station system available to every module below. The key is a Symbol, which guarantees that no other library overwrites our key:
1import { ref, provide, inject } from 'vue'
2
3// Symbol as key - avoids name collisions
4const MissionContextKey = Symbol('MissionContext')
5
6// Composable providing context
7function provideMissionContext() {
8 const missionName = ref('Mars Exploration 2087')
9 const crewCount = ref(6)
10 const missionDay = ref(1)
11
12 const context = {
13 missionName,
14 crewCount,
15 missionDay,
16 advanceDay() { missionDay.value++ }
17 }
18
19 provide(MissionContextKey, context)
20 return context
21}
22
23// Composable consuming context
24function useMissionContext() {
25 const context = inject(MissionContextKey)
26 if (!context) {
27 throw new Error(
28 'useMissionContext requires provideMissionContext in a parent component'
29 )
30 }
31 return context
32}If someone calls useMissionContext without a provider higher up in the tree, they immediately get a clear error instead of a mysterious undefined. You call both composables in <script setup>, because provide and inject only work while the component is being created:
1<!-- ParentComponent.vue -->
2<script setup>
3const { missionName, missionDay, advanceDay } = provideMissionContext()
4</script>
5
6<!-- ChildComponent.vue (any nesting depth) -->
7<script setup>
8const { missionName, missionDay } = useMissionContext()
9// Automatically reactive - changes in parent update children
10</script>The context contains refs, so when the parent calls advanceDay(), every child immediately sees the new mission day.
Error Handling in Composables
Every station system needs safeguards in case of failure. The composable below wraps any async function: it tracks the loading state, stores the error in a ref instead of losing it, and allows at most three retries:
1import { ref, computed } from 'vue'
2
3function useAsyncOperation(asyncFn) {
4 const data = ref(null)
5 const error = ref(null)
6 const loading = ref(false)
7 const retryCount = ref(0)
8 const MAX_RETRIES = 3
9
10 const hasError = computed(() => error.value !== null)
11 const canRetry = computed(() => retryCount.value < MAX_RETRIES)
12
13 async function execute(...args) {
14 loading.value = true
15 error.value = null
16
17 try {
18 data.value = await asyncFn(...args)
19 retryCount.value = 0 // Reset on success
20 } catch (e) {
21 error.value = e.message || 'Unknown error'
22 console.error(`Operation failed: ${error.value}`)
23 } finally {
24 loading.value = false
25 }
26 }
27
28 async function retry(...args) {
29 if (!canRetry.value) {
30 error.value = 'Retry limit exceeded'
31 return
32 }
33 retryCount.value++
34 await execute(...args)
35 }
36
37 return { data, error, loading, hasError, canRetry, retryCount, execute, retry }
38}The try/catch/finally block guarantees that loading returns to false whatever the result. After three failed retries, retry stops calling the server and sets the retry limit message. This is how it is used with a temperature sensor:
1// Usage
2const sensorData = useAsyncOperation(async (sensorId) => {
3 const response = await fetch(`/api/sensors/${sensorId}`)
4 if (!response.ok) throw new Error('Failed to fetch data')
5 return response.json()
6})
7
8// In a component
9await sensorData.execute('temp-sensor-1')
10if (sensorData.hasError.value && sensorData.canRetry.value) {
11 await sensorData.retry('temp-sensor-1')
12}Watch out for the last lines: a top-level await in <script setup> turns the component into an async dependency that requires <Suspense> - you remember that from the Module Storage. In practice, call execute in onMounted or in a click handler.
My advice: return a plain object of refs from a composable, as in every example in this lesson. The component can then destructure it without losing reactivity. In the next lesson we will use these building blocks to create classic design patterns. In the editor below, the resource managers, the monitor with notifications and the operation with retries all work on one panel.
Remember: a composable is a station module with a standard connector - the factory produces as many as you need, and each has its own independent state.
Code for this lesson: App.vue
1<script setup>
2import { ref, computed, provide, inject, readonly } from 'vue'
3
4// ===== FACTORY COMPOSABLE =====
5// Creates a composable for any station resource
6function createResourceManager(resourceName, maxCapacity = 100) {
7 const currentLevel = ref(maxCapacity)
8 const isLow = computed(() => currentLevel.value < maxCapacity * 0.2)
9 const isCritical = computed(() => currentLevel.value < maxCapacity * 0.05)
10 const percentage = computed(() =>
11 Math.round((currentLevel.value / maxCapacity) * 100)
12 )
13
14 function consume(amount) {
15 currentLevel.value = Math.max(0, currentLevel.value - amount)
16 }
17
18 function refill(amount) {
19 currentLevel.value = Math.min(maxCapacity, currentLevel.value + amount)
20 }
21
22 return {
23 resourceName,
24 currentLevel,
25 maxCapacity,
26 percentage,
27 isLow,
28 isCritical,
29 consume,
30 refill
31 }
32}
33
34// ===== COMPOSABLE COMPOSITION =====
35// useNotifications is used inside useLabMonitor
36function useNotifications() {
37 const notifications = ref([])
38
39 function addNotification(message, type = 'info') {
40 notifications.value.unshift({
41 id: Date.now(),
42 message,
43 type,
44 timestamp: new Date().toLocaleTimeString()
45 })
46 if (notifications.value.length > 20) notifications.value.pop()
47 }
48
49 function clearAll() { notifications.value = [] }
50
51 return { notifications, addNotification, clearAll }
52}
53
54// ===== ERROR HANDLING =====
55function useAsyncOperation(asyncFn) {
56 const data = ref(null)
57 const error = ref(null)
58 const loading = ref(false)
59 const retryCount = ref(0)
60
61 async function execute(...args) {
62 loading.value = true
63 error.value = null
64 try {
65 data.value = await asyncFn(...args)
66 } catch (e) {
67 error.value = e.message || 'Unknown error'
68 } finally {
69 loading.value = false
70 }
71 }
72
73 async function retry(...args) {
74 if (retryCount.value >= 3) {
75 error.value = 'Max retries reached'
76 return
77 }
78 retryCount.value++
79 await execute(...args)
80 }
81
82 return { data, error, loading, retryCount, execute, retry }
83}
84
85// === Create Resources (Factory pattern) ===
86const oxygen = createResourceManager('Oxygen', 500)
87const fuel = createResourceManager('Fuel', 1000)
88const water = createResourceManager('Water', 200)
89
90// === Use Notifications (Composition) ===
91const { notifications, addNotification, clearAll } = useNotifications()
92
93// === Async Operation (Error Handling) ===
94const sensorOp = useAsyncOperation(async (id) => {
95 // Simulate API
96 await new Promise(r => setTimeout(r, 800))
97 if (Math.random() < 0.3) throw new Error('Connection lost')
98 return { id, temp: Math.round(Math.random() * 50 - 20), status: 'OK' }
99})
100
101function consumeResource(resource, amount) {
102 resource.consume(amount)
103 if (resource.isCritical.value) {
104 addNotification(resource.resourceName + ' CRITICAL!', 'danger')
105 } else if (resource.isLow.value) {
106 addNotification(resource.resourceName + ' low', 'warning')
107 }
108}
109</script>
110
111<template>
112 <div class="lab">
113 <h1>Advanced Composables - NOVA LAB</h1>
114
115 <section class="resources">
116 <h2>Factory Composables - Resources</h2>
117 <div class="res-grid">
118 <div v-for="res in [oxygen, fuel, water]" :key="res.resourceName" class="res-card"
119 :class="{ low: res.isLow.value, critical: res.isCritical.value }">
120 <h3>{{ res.resourceName }}</h3>
121 <div class="bar"><div class="fill" :style="{ width: res.percentage.value + '%' }"></div></div>
122 <p>{{ res.currentLevel.value }} / {{ res.maxCapacity }} ({{ res.percentage.value }}%)</p>
123 <div class="btns">
124 <button @click="consumeResource(res, 20)">Use 20</button>
125 <button @click="consumeResource(res, 50)">Use 50</button>
126 <button @click="res.refill(30)">Refill 30</button>
127 </div>
128 </div>
129 </div>
130 </section>
131
132 <section class="async-section">
133 <h2>Error Handling - Async Operation</h2>
134 <button @click="sensorOp.execute('T-01')" :disabled="sensorOp.loading.value">
135 {{ sensorOp.loading.value ? 'Loading...' : 'Fetch Sensor' }}
136 </button>
137 <button v-if="sensorOp.error.value" @click="sensorOp.retry('T-01')">
138 Retry ({{ sensorOp.retryCount.value }}/3)
139 </button>
140 <div v-if="sensorOp.data.value" class="result ok">{{ JSON.stringify(sensorOp.data.value) }}</div>
141 <div v-if="sensorOp.error.value" class="result err">Error: {{ sensorOp.error.value }}</div>
142 </section>
143
144 <section class="notif-section">
145 <h2>Notifications (Composition)</h2>
146 <button @click="clearAll" v-if="notifications.length">Clear All</button>
147 <div v-for="n in notifications" :key="n.id" class="notif" :class="n.type">
148 <span class="time">{{ n.timestamp }}</span> {{ n.message }}
149 </div>
150 <p v-if="!notifications.length" class="empty">No notifications</p>
151 </section>
152 </div>
153</template>
154
155<style scoped>
156.lab { background: #0a0e27; color: #e0e0ff; padding: 1.5rem; min-height: 100vh; font-family: 'Courier New', monospace; }
157h1 { color: #00ff88; text-align: center; margin-bottom: 2rem; }
158h2 { color: #00b4d8; border-bottom: 1px solid #00b4d8; padding-bottom: 0.5rem; }
159.res-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1rem 0; }
160.res-card { background: rgba(0,180,216,0.1); border: 2px solid #00b4d8; border-radius: 8px; padding: 1rem; }
161.res-card.low { border-color: #ffb400; }
162.res-card.critical { border-color: #ff0055; animation: pulse 1s infinite; }
163.res-card h3 { color: #00ff88; margin: 0 0 0.5rem; }
164.bar { background: #1a1e3f; border-radius: 4px; height: 16px; overflow: hidden; margin: 0.5rem 0; }
165.fill { height: 100%; background: linear-gradient(90deg, #00ff88, #00b4d8); transition: width 0.3s; border-radius: 4px; }
166.res-card.low .fill { background: linear-gradient(90deg, #ffb400, #ff8800); }
167.res-card.critical .fill { background: #ff0055; }
168.btns { display: flex; gap: 0.3rem; flex-wrap: wrap; margin-top: 0.5rem; }
169button { background: #00b4d8; color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer; font-family: inherit; }
170button:hover { background: #00ff88; color: #0a0e27; }
171button:disabled { opacity: 0.5; }
172.async-section { margin: 1.5rem 0; }
173.async-section button { margin-right: 0.5rem; }
174.result { padding: 0.5rem; border-radius: 4px; margin-top: 0.5rem; font-size: 0.9rem; }
175.result.ok { background: rgba(0,255,136,0.1); border: 1px solid #00ff88; }
176.result.err { background: rgba(255,0,85,0.1); border: 1px solid #ff0055; color: #ff0055; }
177.notif-section { margin: 1.5rem 0; }
178.notif { padding: 0.4rem 0.8rem; margin: 0.3rem 0; border-radius: 4px; font-size: 0.85rem; }
179.notif.info { background: rgba(0,180,216,0.15); border-left: 3px solid #00b4d8; }
180.notif.warning { background: rgba(255,180,0,0.15); border-left: 3px solid #ffb400; }
181.notif.danger { background: rgba(255,0,85,0.15); border-left: 3px solid #ff0055; }
182.time { color: #888; margin-right: 0.5rem; }
183.empty { color: #666; font-style: italic; }
184@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } }
185</style>Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is a factory composable?
2. What is the main advantage of composable composition (a composable using another composable)?
These are 2 of 4 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Build createResourceManager that accepts name and maxCapacity. Return currentLevel (ref), percentage (computed), consume() and refill().
- Click in order
Arrange the correct provide syntax with Symbol:
- Code editor
Build useAsyncOperation with execute(), retry(), refs: data, error, loading, retryCount and computed: hasError, canRetry.
- Horizontal ordering
Arrange the correct inject call with the context key: