Vue.js course · Module 9: Composables & VueUse
Project: Lab Manager with Composables
In this lesson4
A complete NOVA LAB laboratory management system using all the techniques you've learned! Dr. Nova needs a panel that fetches the list of experiments from the server, lets you search, filter and sort it, splits the results into pages and allows deleting entries. If you wrote all of that into one component, you would end up with several hundred lines in which data fetching gets mixed up with pagination. Instead we will build three composables, each with one job, and a component that connects them. Data flows along one path: useExperiments() fetches and stores the experiments, useLabFilters(experiments) filters them, usePagination(filtered) splits the result into pages, and the template displays paginatedItems.
The Data Layer: useExperiments
The first file, composables/useExperiments.js, is responsible for the data. It returns the state, the loading and error flags and the CRUD functions - short for Create, Read, Update, Delete. Derived values, such as the list of critical experiments and the number of categories, are calculated by computed:
1// composables/useExperiments.js
2import { ref, computed } from 'vue'
3
4export function useExperiments() {
5 const experiments = ref([])
6 const loading = ref(false)
7 const error = ref(null)
8
9 const criticalExperiments = computed(() =>
10 experiments.value.filter(exp => exp.critical)
11 )
12
13 const categoriesCount = computed(() =>
14 new Set(experiments.value.map(exp => exp.category)).size
15 )
16
17 async function fetchExperiments() {
18 loading.value = true
19 error.value = null
20
21 try {
22 const response = await fetch('/api/mars/experiments')
23 if (!response.ok) throw new Error('Failed to fetch')
24
25 experiments.value = await response.json()
26 } catch (e) {
27 error.value = e.message
28 } finally {
29 loading.value = false
30 }
31 }
32
33 async function addExperiment(experiment) {
34 const response = await fetch('/api/mars/experiments', {
35 method: 'POST',
36 headers: { 'Content-Type': 'application/json' },
37 body: JSON.stringify(experiment)
38 })
39
40 const newExperiment = await response.json()
41 experiments.value.push(newExperiment)
42 }
43
44 async function deleteExperiment(id) {
45 await fetch(`/api/mars/experiments/${id}`, { method: 'DELETE' })
46 experiments.value = experiments.value.filter(exp => exp.id !== id)
47 }
48
49 return {
50 experiments,
51 loading,
52 error,
53 criticalExperiments,
54 categoriesCount,
55 fetchExperiments,
56 addExperiment,
57 deleteExperiment
58 }
59}We have reading, creating and deleting; you will add updating as an extension. It's also worth adding two things from the previous lessons right away: a response.ok check in addExperiment and an AbortController that cancels the fetch on unmount.
Filtering: useLabFilters
The second composable takes a parameter - a ref with the list of experiments - and builds a filtered, sorted version from it. It knows nothing about the server, so it works with any list whose items have name and category fields:
1// composables/useLabFilters.js
2import { ref, computed } from 'vue'
3
4export function useLabFilters(experiments) {
5 const searchQuery = ref('')
6 const selectedCategory = ref('')
7 const sortBy = ref('name')
8 const sortOrder = ref('asc')
9
10 const filteredExperiments = computed(() => {
11 let result = experiments.value
12
13 // Search
14 if (searchQuery.value) {
15 const query = searchQuery.value.toLowerCase()
16 result = result.filter(exp =>
17 exp.name.toLowerCase().includes(query) ||
18 exp.category.toLowerCase().includes(query)
19 )
20 }
21
22 // Filter by category
23 if (selectedCategory.value) {
24 result = result.filter(exp => exp.category === selectedCategory.value)
25 }
26
27 // Sort
28 result = [...result].sort((a, b) => {
29 const aVal = a[sortBy.value]
30 const bVal = b[sortBy.value]
31 const order = sortOrder.value === 'asc' ? 1 : -1
32
33 if (typeof aVal === 'string') {
34 return aVal.localeCompare(bVal) * order
35 }
36 return (aVal - bVal) * order
37 })
38
39 return result
40 })
41
42 const uniqueCategories = computed(() =>
43 [...new Set(experiments.value.map(exp => exp.category))].sort()
44 )
45
46 function resetFilters() {
47 searchQuery.value = ''
48 selectedCategory.value = ''
49 sortBy.value = 'name'
50 sortOrder.value = 'asc'
51 }
52
53 return {
54 searchQuery,
55 selectedCategory,
56 sortBy,
57 sortOrder,
58 filteredExperiments,
59 uniqueCategories,
60 resetFilters
61 }
62}Note the [...result].sort: the sort method changes an array in place, so without the copy we would sort the original list from useExperiments. Strings are compared with localeCompare, and numbers, such as the year, with plain subtraction.
Pagination: usePagination
The third composable splits any list into pages. The number of pages is calculated by a computed with Math.ceil, and the current slice by slice:
1// composables/usePagination.js
2import { ref, computed } from 'vue'
3
4export function usePagination(items, itemsPerPage = 12) {
5 const currentPage = ref(1)
6 const perPage = ref(itemsPerPage)
7
8 const totalPages = computed(() =>
9 Math.ceil(items.value.length / perPage.value)
10 )
11
12 const paginatedItems = computed(() => {
13 const start = (currentPage.value - 1) * perPage.value
14 const end = start + perPage.value
15 return items.value.slice(start, end)
16 })
17
18 function goToPage(page) {
19 if (page >= 1 && page <= totalPages.value) {
20 currentPage.value = page
21 }
22 }
23
24 function nextPage() {
25 goToPage(currentPage.value + 1)
26 }
27
28 function prevPage() {
29 goToPage(currentPage.value - 1)
30 }
31
32 return {
33 currentPage,
34 perPage,
35 totalPages,
36 paginatedItems,
37 goToPage,
38 nextPage,
39 prevPage
40 }
41}goToPage ignores numbers outside the range. One trap: if you are on page 3 and narrow the filter to a single page of results, currentPage stays at 3 and the list shows up empty. The fix is one line in the composable, plus a watch import: watch(items, () => { currentPage.value = 1 }).
The LabManager Component
Time to connect the modules in the panel. The first part of the template is the header with counters and the filter panel, in which v-model binds the form fields to the refs from useLabFilters:
1<!-- LabManager.vue -->
2<template>
3 <div class="lab-manager">
4 <!-- Header -->
5 <header>
6 <h1>NOVA LAB - Experiment Management</h1>
7 <p>{{ categoriesCount }} categories, {{ experiments.length }} experiments</p>
8 </header>
9
10 <!-- Filters -->
11 <aside class="filters">
12 <input v-model="searchQuery" placeholder="Search experiments..." />
13
14 <select v-model="selectedCategory">
15 <option value="">All categories</option>
16 <option v-for="category in uniqueCategories" :key="category" :value="category">
17 {{ category }}
18 </option>
19 </select>
20
21 <select v-model="sortBy">
22 <option value="name">Name</option>
23 <option value="category">Category</option>
24 <option value="year">Year</option>
25 </select>
26
27 <button @click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'">
28 {{ sortOrder === 'asc' ? '↑' : '↓' }}
29 </button>
30
31 <button @click="resetFilters">Reset</button>
32 </aside>Every change in the search field immediately recalculates filteredExperiments, and after it paginatedItems - without a single line of event-handling code. The second part shows the loading state, the grid of cards and the pagination. We skip the ExperimentCard component - it displays an experiment card and emits a delete event:
1 <!-- Loading/Error -->
2 <div v-if="loading">Loading experiments...</div>
3 <div v-else-if="error">Error: {{ error }}</div>
4
5 <!-- Lab Dashboard -->
6 <main v-else class="lab-grid">
7 <ExperimentCard
8 v-for="experiment in paginatedItems"
9 :key="experiment.id"
10 :experiment="experiment"
11 @delete="deleteExperiment(experiment.id)"
12 />
13 </main>
14
15 <!-- Pagination -->
16 <footer class="pagination">
17 <button @click="prevPage" :disabled="currentPage === 1">
18 Previous
19 </button>
20
21 <button
22 v-for="page in totalPages"
23 :key="page"
24 @click="goToPage(page)"
25 :class="{ active: currentPage === page }"
26 >
27 {{ page }}
28 </button>
29
30 <button @click="nextPage" :disabled="currentPage === totalPages">
31 Next
32 </button>
33 </footer>
34 </div>
35</template>v-for="page in totalPages" iterates over a number, so it yields 1, 2, 3 and so on up to totalPages. Finally the script, in which you import the composables, destructure the returned API and pass the result of one as the parameter of the next:
1<script setup>
2import { onMounted } from 'vue'
3import { useExperiments } from './composables/useExperiments'
4import { useLabFilters } from './composables/useLabFilters'
5import { usePagination } from './composables/usePagination'
6
7// Composables
8const {
9 experiments,
10 loading,
11 error,
12 categoriesCount,
13 fetchExperiments,
14 deleteExperiment
15} = useExperiments()
16
17const {
18 searchQuery,
19 selectedCategory,
20 sortBy,
21 sortOrder,
22 filteredExperiments,
23 uniqueCategories,
24 resetFilters
25} = useLabFilters(experiments)
26
27const {
28 currentPage,
29 totalPages,
30 paginatedItems,
31 goToPage,
32 nextPage,
33 prevPage
34} = usePagination(filteredExperiments, 12)
35
36// Lifecycle
37onMounted(() => {
38 fetchExperiments()
39})
40</script>The order of the calls mirrors the data flow: experiments goes into the filters, filteredExperiments into the pagination, and we fetch the data in onMounted. The component contains no business logic - it only glues it together.
My advice: stick to the rule of one job per composable. Thanks to it you can use usePagination in any other list on the station, and test each composable separately, as in the lesson on testing. In the editor below you will find a related example of module integration: useTimer, useModuleStatus and useNotifications come together in one panel following the same rule.
Remember: the Lab Manager is a station built from modules - each composable does one thing, and the component only connects them.
Code for this lesson: App.vue
1<script setup>
2import { ref, computed, watch, onMounted } from 'vue'
3
4// Best Practice 1: Single Responsibility
5function useTimer(initialSeconds = 0) {
6 const seconds = ref(initialSeconds)
7 const isRunning = ref(false)
8 let intervalId = null
9
10 const formattedTime = computed(() => {
11 const mins = Math.floor(seconds.value / 60)
12 const secs = seconds.value % 60
13 return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
14 })
15
16 function start() {
17 if (isRunning.value) return
18 isRunning.value = true
19 intervalId = setInterval(() => {
20 seconds.value++
21 }, 1000)
22 }
23
24 function pause() {
25 isRunning.value = false
26 if (intervalId) {
27 clearInterval(intervalId)
28 intervalId = null
29 }
30 }
31
32 function reset() {
33 pause()
34 seconds.value = initialSeconds
35 }
36
37 return {
38 seconds,
39 formattedTime,
40 isRunning,
41 start,
42 pause,
43 reset
44 }
45}
46
47// Best Practice 2: Composable Composition
48function useModuleStatus(moduleName) {
49 const isOnline = ref(true)
50 const temperature = ref(22)
51 const powerConsumption = ref(45)
52
53 const status = computed(() => {
54 if (!isOnline.value) return { level: 'offline', color: '#666666' }
55 if (temperature.value > 80) return { level: 'critical', color: '#ff0055' }
56 if (powerConsumption.value > 80) return { level: 'warning', color: '#ffb400' }
57 return { level: 'optimal', color: '#00ff88' }
58 })
59
60 // Use timer composable
61 const uptime = useTimer()
62
63 // Auto-start uptime timer when online
64 watch(isOnline, (newValue) => {
65 if (newValue) {
66 uptime.start()
67 } else {
68 uptime.pause()
69 }
70 }, { immediate: true })
71
72 function toggleOnline() {
73 isOnline.value = !isOnline.value
74 }
75
76 function adjustTemperature(delta) {
77 temperature.value = Math.max(0, Math.min(100, temperature.value + delta))
78 }
79
80 function adjustPower(delta) {
81 powerConsumption.value = Math.max(0, Math.min(100, powerConsumption.value + delta))
82 }
83
84 return {
85 moduleName: ref(moduleName),
86 isOnline,
87 temperature,
88 powerConsumption,
89 status,
90 uptime,
91 toggleOnline,
92 adjustTemperature,
93 adjustPower
94 }
95}
96
97// Best Practice 3: Options with defaults
98function useNotifications(options = {}) {
99 const {
100 maxNotifications = 10,
101 autoRemove = true,
102 autoRemoveDelay = 5000
103 } = options
104
105 const notifications = ref([])
106 let notificationId = 0
107
108 function add(message, type = 'info') {
109 notificationId++
110 const notification = {
111 id: notificationId,
112 message,
113 type,
114 timestamp: new Date()
115 }
116
117 notifications.value.unshift(notification)
118
119 if (notifications.value.length > maxNotifications) {
120 notifications.value.pop()
121 }
122
123 if (autoRemove) {
124 setTimeout(() => {
125 remove(notification.id)
126 }, autoRemoveDelay)
127 }
128
129 return notification
130 }
131
132 function remove(id) {
133 const index = notifications.value.findIndex(n => n.id === id)
134 if (index !== -1) {
135 notifications.value.splice(index, 1)
136 }
137 }
138
139 function clear() {
140 notifications.value = []
141 }
142
143 return {
144 notifications,
145 add,
146 remove,
147 clear
148 }
149}
150
151// Initialize modules
152const habitatModule = useModuleStatus('Habitat Module')
153const researchModule = useModuleStatus('Research Module')
154const powerModule = useModuleStatus('Power Module')
155
156// Initialize notifications
157const notify = useNotifications({
158 maxNotifications: 5,
159 autoRemove: true,
160 autoRemoveDelay: 4000
161})
162
163// Module simulation
164function simulateHeatWave(module) {
165 module.adjustTemperature(30)
166 notify.add(`Heat wave detected in ${module.moduleName.value}`, 'warning')
167}
168
169function simulateCooling(module) {
170 module.adjustTemperature(-20)
171 notify.add(`Cooling system activated in ${module.moduleName.value}`, 'info')
172}
173
174function simulatePowerSpike(module) {
175 module.adjustPower(40)
176 notify.add(`Power spike in ${module.moduleName.value}`, 'warning')
177}
178
179onMounted(() => {
180 notify.add('NOVA LAB Module Integration System Online', 'success')
181})
182</script>
183
184<template>
185 <div class="integration-lab">
186 <div class="header">
187 <h1>Module Integration - NOVA LAB</h1>
188 <p class="subtitle">Module 6: Best Practices & Composable Patterns</p>
189 </div>
190
191 <!-- Notifications Panel -->
192 <div class="notifications-panel">
193 <div class="notifications-header">
194 <h3>System Notifications</h3>
195 <button @click="notify.clear()" class="btn btn-sm btn-danger">Clear All</button>
196 </div>
197 <div class="notifications-list">
198 <div
199 v-for="notification in notify.notifications.value"
200 :key="notification.id"
201 class="notification"
202 :class="'notification-' + notification.type"
203 >
204 <span class="notification-type">{{ notification.type.toUpperCase() }}</span>
205 <span class="notification-message">{{ notification.message }}</span>
206 <button @click="notify.remove(notification.id)" class="btn-close">×</button>
207 </div>
208 </div>
209 </div>
210
211 <!-- Modules Grid -->
212 <div class="modules-grid">
213 <!-- Habitat Module -->
214 <div class="module-card" :style="{ borderColor: habitatModule.status.value.color }">
215 <div class="module-header">
216 <h3>{{ habitatModule.moduleName.value }}</h3>
217 <div
218 class="status-badge"
219 :style="{ background: habitatModule.status.value.color }"
220 >
221 {{ habitatModule.status.value.level.toUpperCase() }}
222 </div>
223 </div>
224
225 <div class="module-stats">
226 <div class="stat">
227 <span class="stat-label">Temperature:</span>
228 <span class="stat-value">{{ habitatModule.temperature.value }}°C</span>
229 </div>
230 <div class="stat">
231 <span class="stat-label">Power:</span>
232 <span class="stat-value">{{ habitatModule.powerConsumption.value }}%</span>
233 </div>
234 <div class="stat">
235 <span class="stat-label">Uptime:</span>
236 <span class="stat-value">{{ habitatModule.uptime.formattedTime.value }}</span>
237 </div>
238 </div>
239
240 <div class="module-controls">
241 <button
242 @click="habitatModule.toggleOnline()"
243 class="btn"
244 :class="habitatModule.isOnline.value ? 'btn-warning' : 'btn-success'"
245 >
246 {{ habitatModule.isOnline.value ? 'Shutdown' : 'Power On' }}
247 </button>
248 </div>
249
250 <div class="module-simulations">
251 <button @click="simulateHeatWave(habitatModule)" class="btn btn-sm">Heat Wave</button>
252 <button @click="simulateCooling(habitatModule)" class="btn btn-sm">Cooling</button>
253 <button @click="simulatePowerSpike(habitatModule)" class="btn btn-sm">Power Spike</button>
254 </div>
255 </div>
256
257 <!-- Research Module -->
258 <div class="module-card" :style="{ borderColor: researchModule.status.value.color }">
259 <div class="module-header">
260 <h3>{{ researchModule.moduleName.value }}</h3>
261 <div
262 class="status-badge"
263 :style="{ background: researchModule.status.value.color }"
264 >
265 {{ researchModule.status.value.level.toUpperCase() }}
266 </div>
267 </div>
268
269 <div class="module-stats">
270 <div class="stat">
271 <span class="stat-label">Temperature:</span>
272 <span class="stat-value">{{ researchModule.temperature.value }}°C</span>
273 </div>
274 <div class="stat">
275 <span class="stat-label">Power:</span>
276 <span class="stat-value">{{ researchModule.powerConsumption.value }}%</span>
277 </div>
278 <div class="stat">
279 <span class="stat-label">Uptime:</span>
280 <span class="stat-value">{{ researchModule.uptime.formattedTime.value }}</span>
281 </div>
282 </div>
283
284 <div class="module-controls">
285 <button
286 @click="researchModule.toggleOnline()"
287 class="btn"
288 :class="researchModule.isOnline.value ? 'btn-warning' : 'btn-success'"
289 >
290 {{ researchModule.isOnline.value ? 'Shutdown' : 'Power On' }}
291 </button>
292 </div>
293
294 <div class="module-simulations">
295 <button @click="simulateHeatWave(researchModule)" class="btn btn-sm">Heat Wave</button>
296 <button @click="simulateCooling(researchModule)" class="btn btn-sm">Cooling</button>
297 <button @click="simulatePowerSpike(researchModule)" class="btn btn-sm">Power Spike</button>
298 </div>
299 </div>
300
301 <!-- Power Module -->
302 <div class="module-card" :style="{ borderColor: powerModule.status.value.color }">
303 <div class="module-header">
304 <h3>{{ powerModule.moduleName.value }}</h3>
305 <div
306 class="status-badge"
307 :style="{ background: powerModule.status.value.color }"
308 >
309 {{ powerModule.status.value.level.toUpperCase() }}
310 </div>
311 </div>
312
313 <div class="module-stats">
314 <div class="stat">
315 <span class="stat-label">Temperature:</span>
316 <span class="stat-value">{{ powerModule.temperature.value }}°C</span>
317 </div>
318 <div class="stat">
319 <span class="stat-label">Power:</span>
320 <span class="stat-value">{{ powerModule.powerConsumption.value }}%</span>
321 </div>
322 <div class="stat">
323 <span class="stat-label">Uptime:</span>
324 <span class="stat-value">{{ powerModule.uptime.formattedTime.value }}</span>
325 </div>
326 </div>
327
328 <div class="module-controls">
329 <button
330 @click="powerModule.toggleOnline()"
331 class="btn"
332 :class="powerModule.isOnline.value ? 'btn-warning' : 'btn-success'"
333 >
334 {{ powerModule.isOnline.value ? 'Shutdown' : 'Power On' }}
335 </button>
336 </div>
337
338 <div class="module-simulations">
339 <button @click="simulateHeatWave(powerModule)" class="btn btn-sm">Heat Wave</button>
340 <button @click="simulateCooling(powerModule)" class="btn btn-sm">Cooling</button>
341 <button @click="simulatePowerSpike(powerModule)" class="btn btn-sm">Power Spike</button>
342 </div>
343 </div>
344 </div>
345
346 <div class="info-panel">
347 <h3>Best Practices for Composables</h3>
348 <div class="info-grid">
349 <div class="info-section">
350 <h4>1. Single Responsibility</h4>
351 <ul>
352 <li>Each composable has a single responsibility</li>
353 <li>useTimer - only timer management</li>
354 <li>Easy to test and maintain</li>
355 </ul>
356 </div>
357
358 <div class="info-section">
359 <h4>2. Composable Composition</h4>
360 <ul>
361 <li>Combining composables for complex logic</li>
362 <li>useModuleStatus uses useTimer</li>
363 <li>Reusability and modularity</li>
364 </ul>
365 </div>
366
367 <div class="info-section">
368 <h4>3. Options Pattern</h4>
369 <ul>
370 <li>Accepting options with defaults</li>
371 <li>useNotifications({ maxNotifications: 5 })</li>
372 <li>Flexibility and customization</li>
373 </ul>
374 </div>
375
376 <div class="info-section">
377 <h4>4. Naming Convention</h4>
378 <ul>
379 <li>Always prefix with "use"</li>
380 <li>Descriptive name: useModuleStatus</li>
381 <li>Easy to identify</li>
382 </ul>
383 </div>
384 </div>
385
386 <div class="code-example">
387 <code>
388// Good: Single responsibility
389function useTimer() { ... }
390
391// Good: Composition
392function useModule() {
393 const timer = useTimer()
394 return { timer, ... }
395}
396
397// Good: Options with defaults
398function useNotify(options = {}) {
399 const { maxItems = 10 } = options
400 ...
401}
402 </code>
403 </div>
404 </div>
405 </div>
406</template>
407
408<style scoped>
409.integration-lab {
410 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
411 min-height: 100vh;
412 padding: 2rem;
413 color: #ffffff;
414 font-family: 'Courier New', monospace;
415}
416
417.header {
418 text-align: center;
419 margin-bottom: 2rem;
420 padding-bottom: 1rem;
421 border-bottom: 2px solid #00ff88;
422}
423
424.header h1 {
425 font-size: 2.5rem;
426 color: #00ff88;
427 text-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
428 margin: 0;
429}
430
431.subtitle {
432 color: #00b4d8;
433 font-size: 1.2rem;
434 margin-top: 0.5rem;
435}
436
437.notifications-panel {
438 background: rgba(0, 180, 216, 0.05);
439 border: 2px solid #00b4d8;
440 border-radius: 10px;
441 padding: 1.5rem;
442 margin-bottom: 2rem;
443}
444
445.notifications-header {
446 display: flex;
447 justify-content: space-between;
448 align-items: center;
449 margin-bottom: 1rem;
450}
451
452.notifications-header h3 {
453 color: #00b4d8;
454 margin: 0;
455}
456
457.notifications-list {
458 display: flex;
459 flex-direction: column;
460 gap: 0.5rem;
461 max-height: 300px;
462 overflow-y: auto;
463}
464
465.notification {
466 display: flex;
467 align-items: center;
468 gap: 1rem;
469 padding: 1rem;
470 border-radius: 5px;
471 border-left: 4px solid;
472 animation: slideIn 0.3s ease;
473}
474
475@keyframes slideIn {
476 from {
477 transform: translateX(-20px);
478 opacity: 0;
479 }
480 to {
481 transform: translateX(0);
482 opacity: 1;
483 }
484}
485
486.notification-success {
487 background: rgba(0, 255, 136, 0.1);
488 border-color: #00ff88;
489}
490
491.notification-info {
492 background: rgba(0, 180, 216, 0.1);
493 border-color: #00b4d8;
494}
495
496.notification-warning {
497 background: rgba(255, 180, 0, 0.1);
498 border-color: #ffb400;
499}
500
501.notification-type {
502 color: #00b4d8;
503 font-size: 0.8rem;
504 font-weight: bold;
505 min-width: 80px;
506}
507
508.notification-message {
509 flex: 1;
510 color: #ffffff;
511}
512
513.btn-close {
514 background: rgba(255, 0, 85, 0.3);
515 color: #ff0055;
516 border: none;
517 width: 30px;
518 height: 30px;
519 border-radius: 50%;
520 font-size: 1.5rem;
521 cursor: pointer;
522 display: flex;
523 align-items: center;
524 justify-content: center;
525 transition: all 0.3s ease;
526}
527
528.btn-close:hover {
529 background: #ff0055;
530 color: #ffffff;
531}
532
533.modules-grid {
534 display: grid;
535 grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
536 gap: 2rem;
537 margin-bottom: 2rem;
538}
539
540.module-card {
541 background: rgba(0, 255, 136, 0.05);
542 border: 3px solid;
543 border-radius: 10px;
544 padding: 1.5rem;
545 transition: all 0.3s ease;
546}
547
548.module-header {
549 display: flex;
550 justify-content: space-between;
551 align-items: center;
552 margin-bottom: 1.5rem;
553}
554
555.module-header h3 {
556 color: #00ff88;
557 margin: 0;
558}
559
560.status-badge {
561 padding: 0.5rem 1rem;
562 border-radius: 20px;
563 font-size: 0.8rem;
564 font-weight: bold;
565 color: #0a0e27;
566}
567
568.module-stats {
569 background: rgba(0, 0, 0, 0.3);
570 border-radius: 8px;
571 padding: 1rem;
572 margin-bottom: 1rem;
573}
574
575.stat {
576 display: flex;
577 justify-content: space-between;
578 padding: 0.5rem 0;
579 border-bottom: 1px solid rgba(255, 255, 255, 0.1);
580}
581
582.stat:last-child {
583 border-bottom: none;
584}
585
586.stat-label {
587 color: #00b4d8;
588}
589
590.stat-value {
591 color: #00ff88;
592 font-weight: bold;
593}
594
595.module-controls {
596 margin: 1rem 0;
597}
598
599.module-simulations {
600 display: flex;
601 gap: 0.5rem;
602 flex-wrap: wrap;
603}
604
605.btn {
606 padding: 0.75rem 1.5rem;
607 border: none;
608 border-radius: 5px;
609 font-weight: bold;
610 cursor: pointer;
611 font-family: 'Courier New', monospace;
612 transition: all 0.3s ease;
613 width: 100%;
614}
615
616.btn-sm {
617 padding: 0.5rem 1rem;
618 font-size: 0.85rem;
619 width: auto;
620 flex: 1;
621 background: rgba(0, 180, 216, 0.3);
622 color: #00b4d8;
623}
624
625.btn-success {
626 background: #00ff88;
627 color: #0a0e27;
628}
629
630.btn-warning {
631 background: #ffb400;
632 color: #0a0e27;
633}
634
635.btn-danger {
636 background: #ff0055;
637 color: #ffffff;
638}
639
640.btn:hover {
641 transform: scale(1.05);
642 box-shadow: 0 5px 15px rgba(0, 255, 136, 0.3);
643}
644
645.info-panel {
646 background: rgba(0, 255, 136, 0.05);
647 border: 2px solid #00ff88;
648 border-radius: 10px;
649 padding: 2rem;
650}
651
652.info-panel h3 {
653 color: #00ff88;
654 margin-top: 0;
655 margin-bottom: 1.5rem;
656 text-align: center;
657}
658
659.info-grid {
660 display: grid;
661 grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
662 gap: 1.5rem;
663 margin-bottom: 2rem;
664}
665
666.info-section {
667 background: rgba(0, 180, 216, 0.1);
668 border: 2px solid #00b4d8;
669 border-radius: 8px;
670 padding: 1.5rem;
671}
672
673.info-section h4 {
674 color: #00b4d8;
675 margin-top: 0;
676 margin-bottom: 1rem;
677}
678
679.info-section ul {
680 list-style: none;
681 padding: 0;
682 margin: 0;
683}
684
685.info-section li {
686 padding: 0.5rem 0;
687 color: #ffffff;
688 font-size: 0.9rem;
689 line-height: 1.6;
690}
691
692.code-example {
693 background: rgba(0, 0, 0, 0.5);
694 border-left: 4px solid #00ff88;
695 border-radius: 5px;
696 padding: 1.5rem;
697}
698
699.code-example code {
700 color: #00ff88;
701 font-size: 0.9rem;
702 white-space: pre;
703 display: block;
704}
705</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 should the useExperiments composable return?
2. What do we use computed() for in composables?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Build useExperiments with fetchExperiments, addExperiment, deleteExperiment, and computed properties.
- Vertical ordering
Arrange the composables workflow from creation to usage:
- Click in order
Arrange the pattern of a composable managing a data list:
- Horizontal ordering
Arrange a composable accepting a parameter:
- Code editor
Build useLabFilters with search, category, sort, and computed filteredItems.
- Vertical ordering
Arrange the steps for integrating multiple composables in a single component:
- Horizontal ordering
Arrange a computed property in a composable:
- Click in order
Arrange the full lifecycle of a composable in an application:
- Vertical ordering
Arrange the Lab Manager architecture from data to view:
- Click in order
Arrange script setup integrating composables:
- Horizontal ordering
Arrange the import path for a composable:
- Vertical ordering
Arrange the steps for initializing a composable that fetches data from a server: