Vue.js course · Module 9: Composables & VueUse

Project: Lab Manager with Composables

7 min read
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. 1. What should the useExperiments composable return?

  2. 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:

Useful articles