Vue.js course · Module 9: Composables & VueUse
Reactive Design Patterns
In this lesson4
In the NOVA LAB control center every system must be reliable and predictable. The problem appears when modules have to cooperate: the theme panel and the notification panel should see the same data, a sensor has to alert several systems at once, and an experiment must not jump from "aborted" straight to "completed". Design patterns with Vue 3 reactivity let you build such systems - like the proven engineering protocols of a space station.
Reactive Singleton
Sometimes we need a single shared instance of state - like the station's central computer that all modules use. The trick is where the state is created: we create the reactive object outside the composable function, at the JavaScript module level, so it exists exactly once. The readonly function returns a read-only version of it:
1import { reactive, readonly } from 'vue'
2
3// State created ONCE, outside the function
4const globalState = reactive({
5 currentUser: null,
6 theme: 'dark',
7 locale: 'pl',
8 notifications: []
9})
10
11// Composable returns the same state every time
12export function useGlobalStore() {
13 function setUser(user) {
14 globalState.currentUser = user
15 }
16
17 function setTheme(theme) {
18 globalState.theme = theme
19 }
20
21 function addNotification(msg) {
22 globalState.notifications.push({
23 id: Date.now(),
24 message: msg,
25 read: false
26 })
27 }
28
29 // readonly prevents direct modification
30 return {
31 state: readonly(globalState),
32 setUser,
33 setTheme,
34 addNotification
35 }
36}Changes go only through the methods, and an attempt to write state.theme = 'light' in a component ends with a console warning while the value stays unchanged. Usage in any component looks like this:
1<!-- Any component - same state -->
2<script setup>
3import { useGlobalStore } from './composables/useGlobalStore'
4
5// Both components see the same data
6const { state, addNotification } = useGlobalStore()
7</script>
8
9<template>
10 <p>Theme: {{ state.theme }}</p>
11 <p>Notifications: {{ state.notifications.length }}</p>
12</template>The key difference: the state is defined outside the composable function, so every call to useGlobalStore() returns a reference to the same object. One caveat: with server-side rendering (SSR), such state would be shared by all users. That is why in large applications this role is taken over by Pinia, which you will meet in the Mission Database.
Event Bus with Reactivity
Vue 3 removed the $on and $off methods from the component instance, which were used to build a global event bus in Vue 2. We can, however, create a reactive event system - like the NOVA LAB station's communication network. The listeners map assigns a list of handler functions to each event name, and the lastEvent ref remembers the last signal. Both are created outside the function, as in the singleton, so all components broadcast on the same network:
1import { ref } from 'vue'
2
3// One network for the whole app - outside the function
4const lastEvent = ref(null)
5const listeners = new Map()
6
7function useEventBus() {
8 function emit(eventName, payload) {
9 lastEvent.value = { name: eventName, payload, timestamp: Date.now() }
10
11 const handlers = listeners.get(eventName) || []
12 handlers.forEach(handler => handler(payload))
13 }
14
15 function on(eventName, handler) {
16 if (!listeners.has(eventName)) {
17 listeners.set(eventName, [])
18 }
19 listeners.get(eventName).push(handler)
20
21 // Return unsubscribe function
22 return () => {
23 const handlers = listeners.get(eventName)
24 const index = handlers.indexOf(handler)
25 if (index > -1) handlers.splice(index, 1)
26 }
27 }
28
29 return { lastEvent, emit, on }
30}If the map were created inside the function, every component would have its own private network, and signals from one module would never reach another. The on method returns a function that removes the handler from the list, and the component has to call it when it unmounts:
1<script setup>
2import { onUnmounted } from 'vue'
3
4const { emit, on } = useEventBus()
5
6// Subscription with automatic cleanup
7const unsubscribe = on('sensor-alert', (data) => {
8 console.log('Alert from sensor:', data)
9})
10
11onUnmounted(() => {
12 unsubscribe() // Important! Prevents memory leaks
13})
14
15// Emitting an event
16emit('sensor-alert', { sensorId: 'T-01', value: 45 })
17</script>Without unsubscribe, an unmounted component would stay in the map forever and keep reacting to alarms - a classic memory leak.
State Machine with ref and computed
State machines are an ideal pattern for managing complex state - like the station's safety protocols, where every state has precisely defined transitions. The machine keeps the current state in a ref and derives the list of allowed events with computed:
1import { ref, computed } from 'vue'
2
3function useStateMachine(config) {
4 const currentState = ref(config.initial)
5
6 const availableTransitions = computed(() => {
7 const stateConfig = config.states[currentState.value]
8 return stateConfig ? Object.keys(stateConfig.on || {}) : []
9 })
10
11 function transition(event) {
12 const stateConfig = config.states[currentState.value]
13 if (!stateConfig || !stateConfig.on || !stateConfig.on[event]) {
14 console.warn(
15 `Illegal transition: ${currentState.value} -> ${event}`
16 )
17 return false
18 }
19
20 const nextState = stateConfig.on[event]
21 currentState.value = nextState
22 return true
23 }
24
25 function canTransition(event) {
26 return availableTransitions.value.includes(event)
27 }
28
29 return { currentState, availableTransitions, transition, canTransition }
30}The transition method rejects an event the current state doesn't allow and returns false instead of corrupting the data. All the knowledge about transitions lives in the configuration object, where each state's on field maps an event to a target state:
1<script setup>
2const experimentMachine = useStateMachine({
3 initial: 'idle',
4 states: {
5 idle: {
6 on: { START: 'running', CONFIGURE: 'configuring' }
7 },
8 configuring: {
9 on: { SAVE: 'idle', CANCEL: 'idle' }
10 },
11 running: {
12 on: { PAUSE: 'paused', COMPLETE: 'completed', FAIL: 'failed' }
13 },
14 paused: {
15 on: { RESUME: 'running', ABORT: 'failed' }
16 },
17 completed: {
18 on: { RESET: 'idle' }
19 },
20 failed: {
21 on: { RESET: 'idle', RETRY: 'running' }
22 }
23 }
24})
25
26// Used in template
27// experimentMachine.currentState.value => 'idle'
28// experimentMachine.transition('START') => state changes to 'running'
29// experimentMachine.canTransition('PAUSE') => true (we are now in 'running')
30</script>PAUSE cannot be triggered from the idle state, so you can disable the pause button with a simple :disabled="!experimentMachine.canTransition('PAUSE')".
Reactive Observer Pattern
The observer pattern with Vue reactivity - like a monitoring system where every sensor can have many observers. Each subscriber is a separate watch, and the function returned by watch stops it on demand:
1import { ref, watch, readonly } from 'vue'
2
3function useObservable(initialValue) {
4 const value = ref(initialValue)
5 const history = ref([])
6 const observers = ref(0)
7
8 // Each watch is a new observer
9 function subscribe(callback) {
10 observers.value++
11 const unwatch = watch(value, (newVal, oldVal) => {
12 callback({ newVal, oldVal, timestamp: Date.now() })
13 })
14
15 return () => {
16 unwatch()
17 observers.value--
18 }
19 }
20
21 function setValue(newValue) {
22 history.value.push({
23 from: value.value,
24 to: newValue,
25 timestamp: Date.now()
26 })
27 if (history.value.length > 100) history.value.shift()
28 value.value = newValue
29 }
30
31 return {
32 value: readonly(value),
33 history: readonly(history),
34 observers,
35 subscribe,
36 setValue
37 }
38}The history keeps at most the 100 most recent changes, and we expose the value and the history as read-only. In the component there is one trap: the template automatically unwraps only top-level refs, not refs tucked inside a plain object. That's why we pull them out with destructuring:
1<script setup>
2import { onUnmounted } from 'vue'
3
4const temperature = useObservable(22)
5// Pull the refs to the top level so the template unwraps them
6const { value: currentTemp, history, observers } = temperature
7
8// Observer 1 - alarm
9const unsub1 = temperature.subscribe(({ newVal }) => {
10 if (newVal > 40) console.warn('Critical temperature!')
11})
12
13// Observer 2 - logging
14const unsub2 = temperature.subscribe(({ newVal, oldVal }) => {
15 console.log(`Temp: ${oldVal} -> ${newVal}`)
16})
17
18onUnmounted(() => {
19 unsub1()
20 unsub2()
21})
22
23// Changing the value notifies all observers
24temperature.setValue(25)
25</script>
26
27<template>
28 <p>Temperature: {{ currentTemp }}°C</p>
29 <p>Active observers: {{ observers }}</p>
30 <p>Change history: {{ history.length }}</p>
31</template>The panel shows 25°C, two observers and one change in the history, and the console prints "Temp: 22 -> 25". If you wrote temperature.history.length in the template, you would see an empty spot, because the nested ref would not be unwrapped.
My advice: before you reach for an event bus, check whether props and emits, provide/inject or a shared store are enough. The Vue documentation discourages a global bus in most situations, because the data flow becomes invisible. VueUse, which you will meet in the next lesson, even has a ready-made useEventBus. In the editor below, the singleton switches the theme, the state machine takes an experiment through its states, and the observed temperature notifies its subscribers.
Remember: a good pattern is a station protocol - it says who may change the state, when, and whom to notify about it.
Code for this lesson: App.vue
1<script setup>
2import { ref, computed, watch, readonly } from 'vue'
3
4// ===== REACTIVE SINGLETON =====
5const globalState = {
6 theme: ref('dark'),
7 locale: ref('pl'),
8 notifications: ref([])
9}
10
11function useGlobalStore() {
12 function setTheme(t) { globalState.theme.value = t }
13 function addNotification(msg) {
14 globalState.notifications.value.push({ id: Date.now(), message: msg })
15 }
16 return {
17 theme: globalState.theme,
18 locale: globalState.locale,
19 notifications: globalState.notifications,
20 setTheme,
21 addNotification
22 }
23}
24
25// ===== STATE MACHINE =====
26function useStateMachine(config) {
27 const currentState = ref(config.initial)
28
29 const availableTransitions = computed(() => {
30 const sc = config.states[currentState.value]
31 return sc ? Object.keys(sc.on || {}) : []
32 })
33
34 function transition(event) {
35 const sc = config.states[currentState.value]
36 if (!sc || !sc.on || !sc.on[event]) return false
37 currentState.value = sc.on[event]
38 return true
39 }
40
41 function canTransition(event) {
42 return availableTransitions.value.includes(event)
43 }
44
45 return { currentState, availableTransitions, transition, canTransition }
46}
47
48// ===== REACTIVE OBSERVER =====
49function useObservable(initialValue) {
50 const value = ref(initialValue)
51 const history = ref([])
52 const observerCount = ref(0)
53
54 function subscribe(callback) {
55 observerCount.value++
56 const unwatch = watch(value, (newVal, oldVal) => {
57 callback({ newVal, oldVal })
58 })
59 return () => { unwatch(); observerCount.value-- }
60 }
61
62 function setValue(newValue) {
63 history.value.push({ from: value.value, to: newValue, time: new Date().toLocaleTimeString() })
64 if (history.value.length > 15) history.value.shift()
65 value.value = newValue
66 }
67
68 return { value, history, observerCount, subscribe, setValue }
69}
70
71// === Instances ===
72const store = useGlobalStore()
73
74const experiment = useStateMachine({
75 initial: 'idle',
76 states: {
77 idle: { on: { START: 'running', CONFIGURE: 'configuring' } },
78 configuring: { on: { SAVE: 'idle', CANCEL: 'idle' } },
79 running: { on: { PAUSE: 'paused', COMPLETE: 'completed', FAIL: 'failed' } },
80 paused: { on: { RESUME: 'running', ABORT: 'failed' } },
81 completed: { on: { RESET: 'idle' } },
82 failed: { on: { RESET: 'idle', RETRY: 'running' } }
83 }
84})
85
86const temperature = useObservable(22)
87
88// Subscribe observers
89const alerts = ref([])
90temperature.subscribe(({ newVal }) => {
91 if (newVal > 35) alerts.value.unshift('ALERT: Temp ' + newVal + '°C!')
92 if (alerts.value.length > 5) alerts.value.pop()
93})
94temperature.subscribe(({ newVal, oldVal }) => {
95 store.addNotification('Temp: ' + oldVal + ' -> ' + newVal)
96})
97</script>
98
99<template>
100 <div class="lab" :class="store.theme.value">
101 <h1>Reactive Patterns - NOVA LAB</h1>
102
103 <section>
104 <h2>Singleton Store</h2>
105 <p>Theme: {{ store.theme.value }} | Locale: {{ store.locale.value }}</p>
106 <button @click="store.setTheme(store.theme.value === 'dark' ? 'light' : 'dark')">Toggle Theme</button>
107 <div class="notifs">
108 <div v-for="n in store.notifications.value.slice(-5)" :key="n.id" class="notif">{{ n.message }}</div>
109 </div>
110 </section>
111
112 <section>
113 <h2>State Machine - Experiment</h2>
114 <div class="state-display">{{ experiment.currentState.value.toUpperCase() }}</div>
115 <div class="transitions">
116 <button v-for="t in experiment.availableTransitions.value" :key="t"
117 @click="experiment.transition(t)" class="trans-btn">
118 {{ t }}
119 </button>
120 </div>
121 <p v-if="!experiment.availableTransitions.value.length" class="info">No transitions (terminal state)</p>
122 </section>
123
124 <section>
125 <h2>Observable - Temperature</h2>
126 <p>Value: {{ temperature.value.value }}°C | Observers: {{ temperature.observerCount.value }}</p>
127 <div class="temp-controls">
128 <button @click="temperature.setValue(temperature.value.value - 5)">-5°C</button>
129 <button @click="temperature.setValue(temperature.value.value + 5)">+5°C</button>
130 <button @click="temperature.setValue(Math.round(Math.random() * 50))">Random</button>
131 </div>
132 <div class="history">
133 <div v-for="(h, i) in temperature.history.value.slice(-8)" :key="i" class="hist-item">
134 {{ h.time }}: {{ h.from }}°C -> {{ h.to }}°C
135 </div>
136 </div>
137 <div v-if="alerts.length" class="alerts">
138 <div v-for="(a, i) in alerts" :key="i" class="alert">{{ a }}</div>
139 </div>
140 </section>
141 </div>
142</template>
143
144<style scoped>
145.lab { background: #0a0e27; color: #e0e0ff; padding: 1.5rem; min-height: 100vh; font-family: 'Courier New', monospace; transition: all 0.3s; }
146.lab.light { background: #f0f0f5; color: #1a1a3f; }
147h1 { color: #00ff88; text-align: center; }
148h2 { color: #00b4d8; border-bottom: 1px solid #00b4d8; padding-bottom: 0.5rem; }
149section { margin: 1.5rem 0; }
150button { background: #00b4d8; color: #fff; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; margin: 0.2rem; font-family: inherit; }
151button:hover { background: #00ff88; color: #0a0e27; }
152.state-display { font-size: 2rem; font-weight: bold; color: #00ff88; text-align: center; padding: 1rem; background: rgba(0,255,136,0.1); border: 2px solid #00ff88; border-radius: 8px; margin: 1rem 0; }
153.transitions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }
154.trans-btn { background: #333; }
155.trans-btn:hover { background: #00b4d8; }
156.temp-controls { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
157.history { background: rgba(0,0,0,0.3); padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; }
158.hist-item { font-size: 0.8rem; color: #aaa; padding: 0.2rem 0; }
159.notifs { margin-top: 0.5rem; }
160.notif { font-size: 0.8rem; color: #00b4d8; padding: 0.2rem 0; }
161.alerts { margin-top: 0.5rem; }
162.alert { background: rgba(255,0,85,0.15); border-left: 3px solid #ff0055; padding: 0.3rem 0.6rem; margin: 0.2rem 0; font-size: 0.85rem; color: #ff4444; }
163.info { color: #666; font-style: italic; }
164</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 does the Reactive Singleton pattern mean in composables?
2. What is the main advantage of the State Machine pattern with ref and computed?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Build useStateMachine with currentState (ref), availableTransitions (computed), transition() and canTransition(). Configuration as a parameter.
- Vertical ordering
Arrange the steps for creating a Reactive Singleton composable:
- Click in order
Arrange the steps for subscribing and emitting an event:
- Code editor
Build useObservable with value (ref), history (ref), subscribe() returning unsubscribe, setValue() and observers count.
- Horizontal ordering
Arrange the state transition call in a State Machine: