Vue.js course Β· Module 9: Composables & VueUse

VueUse - Composables Library

6 min read
In this lesson8

VueUse is a collection of 200+ ready-made composables - like the NOVA LAB library of standard research protocols! Why do you need such a library? Every project brings back the same tasks: remember settings in localStorage, track the window size, the mouse position or dark mode, delay a search until the user stops typing. Each of them requires listening to browser events and cleaning up afterwards. You can write all that from scratch in every project, or reach for proven, tested functions from the @vueuse/core package. Only the functions you actually import end up in the application bundle.

Installing VueUse

You install the library like any npm package. The current version of VueUse requires Vue 3.5 or newer:

1npm install @vueuse/core

After installation you import individual functions from '@vueuse/core', exactly like ref or computed from 'vue'. No plugin registration is needed.

useLocalStorage

The first protocol saves the lab settings so that they survive a page refresh. useLocalStorage takes a key and a default value, and returns a ref synchronized with the browser's localStorage:

1<script setup>
2import { useLocalStorage } from '@vueuse/core'
3
4// Automatically syncs with localStorage
5const labSettings = useLocalStorage('lab-settings', {
6  mode: 'exploration',
7  sensorsPerView: 12,
8  sortBy: 'temperature'
9})
10
11// Changes are automatically saved to localStorage
12labSettings.value.mode = 'research'
13</script>
14
15<template>
16  <div>
17    <p>Mode: {{ labSettings.mode }}</p>
18    <button @click="labSettings.mode = 'maintenance'">
19      Change mode
20    </button>
21  </div>
22</template>

The default value is used only when nothing is stored under the lab-settings key yet. Objects are saved as JSON, and a change to a single field is saved too, because VueUse watches the object deeply.

useMouseInElement

This composable tracks the mouse relative to a specific element. Among other things it returns elementX and elementY, the position measured from the element's top-left corner, and isOutside, which tells you whether the cursor is outside it. The plain x and y are coordinates on the whole page:

1<script setup>
2import { ref } from 'vue'
3import { useMouseInElement } from '@vueuse/core'
4
5const sensorPanel = ref(null)
6const { elementX, elementY, isOutside } = useMouseInElement(sensorPanel)
7</script>
8
9<template>
10  <div ref="sensorPanel" class="sensor-panel">
11    <img src="/sensor-display.jpg" />
12    <div v-if="!isOutside" class="tooltip" :style="{ left: elementX + 'px', top: elementY + 'px' }">
13      Position: {{ elementX }}, {{ elementY }}
14    </div>
15  </div>
16</template>

The sensorPanel ref connects to the element through the ref attribute; since Vue 3.5 you can also use useTemplateRef('sensorPanel'). For the tooltip to follow the cursor, the panel needs position: relative in CSS and the tooltip position: absolute.

useIntersectionObserver

The next protocol checks whether an element is visible in the browser window. It is the basis of lazy-loading images and of animations triggered by scrolling. The callback receives a list of observed entries, and the isIntersecting field tells you whether the element has entered the viewport:

1<script setup>
2import { ref } from 'vue'
3import { useIntersectionObserver } from '@vueuse/core'
4
5const experimentPanel = ref(null)
6const isVisible = ref(false)
7
8useIntersectionObserver(
9  experimentPanel,
10  ([{ isIntersecting }]) => {
11    isVisible.value = isIntersecting
12  }
13)
14</script>
15
16<template>
17  <div ref="experimentPanel">
18    <Transition name="fade">
19      <img v-if="isVisible" src="/experiment-data.jpg" />
20    </Transition>
21  </div>
22</template>

The image is downloaded only when the panel appears on screen. VueUse disconnects the observer by itself when the component unmounts, so you don't have to write your own cleanup.

useWindowSize

useWindowSize returns the window's reactive width and height, which update every time the window is resized. From them it's easy to derive view breakpoints with computed:

1<script setup>
2import { useWindowSize } from '@vueuse/core'
3import { computed } from 'vue'
4
5const { width, height } = useWindowSize()
6
7const isMobile = computed(() => width.value < 768)
8const isTablet = computed(() => width.value >= 768 && width.value < 1024)
9const isDesktop = computed(() => width.value >= 1024)
10</script>
11
12<template>
13  <div>
14    <p v-if="isMobile">Mobile view</p>
15    <p v-else-if="isTablet">Tablet view</p>
16    <p v-else>Desktop view</p>
17    <p>Dimensions: {{ width }} x {{ height }}</p>
18  </div>
19</template>

The template switches the message without any manual resize listener. If you have more breakpoints, VueUse offers the specialized useBreakpoints for that.

useDark & useToggle

useDark returns a ref with the boolean value of dark mode. By default it starts from the system setting, adds the dark class to the <html> element and remembers the choice in localStorage. useToggle turns such a ref into a toggle function:

1<script setup>
2import { useDark, useToggle } from '@vueuse/core'
3
4const isDark = useDark()
5const toggleDark = useToggle(isDark)
6</script>
7
8<template>
9  <div :class="{ dark: isDark }">
10    <button @click="toggleDark()">
11      {{ isDark ? 'Light' : 'Dark' }} theme
12    </button>
13  </div>
14</template>
15
16<style>
17body {
18  transition: background 0.3s, color 0.3s;
19}
20
21.dark {
22  background: #1a1a1a;
23  color: #f5f5f5;
24}
25</style>

After a click, the dark class appears on <html> and on the div with the :class binding. The binding isn't necessary, because useDark itself adds the class to <html>; we keep it to make it visible where the style comes from.

refDebounced and refThrottled

Debounce waits until a value stops changing for a given time, while throttle lets a change through at most once per interval. In VueUse this is done by refDebounced and refThrottled, which return a delayed copy of a ref. The older names useDebounce and useThrottle still work, but they are marked as deprecated:

1<script setup>
2import { ref } from 'vue'
3import { refDebounced, refThrottled } from '@vueuse/core'
4
5// Debounce - waits until the user stops typing
6const searchQuery = ref('')
7const debouncedQuery = refDebounced(searchQuery, 500)
8
9// Throttle - at most once every X ms
10const scrollPosition = ref(0)
11const throttledScroll = refThrottled(scrollPosition, 100)
12</script>
13
14<template>
15  <input v-model="searchQuery" placeholder="Search..." />
16  <p>Searching: {{ debouncedQuery }}</p>
17</template>

When you type "oxy", debouncedQuery changes only half a second after the last key, so the request to the server goes out once instead of three times. You would update scrollPosition in a scroll handler. For delaying function calls, VueUse also has useDebounceFn and useThrottleFn.

Finally, a cheat sheet with the functions you will reach for most often:

ComposableDescription
useLocalStorageReactive localStorage
useFetchFetch with reactivity
useIntersectionObserverVisibility detection
useMouseInElementMouse position in element
useWindowSizeWindow size
useDarkDark mode
refDebouncedDelayed reactivity (debounce)
useClipboardCopy to clipboard

My advice: before you write your own composable for a browser feature, check vueuse.org. If the function is already there, you get code with tests and correct cleanup. In the editor below you will see what such a composable looks like inside: useConnectionMonitor starts its timers in onMounted and cleans them up in onUnmounted - exactly the work VueUse does for you.

Remember: VueUse is a warehouse of ready-made station protocols - before you build a new instrument, check whether it is already on the shelf.

Code for this lesson: App.vue
1<script setup>
2import { ref, onMounted, onUnmounted } from 'vue'
3
4// Composable with lifecycle hooks: useConnectionMonitor
5function useConnectionMonitor(serverUrl) {
6  const isConnected = ref(false)
7  const connectionTime = ref(null)
8  const disconnectionTime = ref(null)
9  const reconnectAttempts = ref(0)
10  const latency = ref(0)
11  const messages = ref([])
12
13  let intervalId = null
14  let connectionCheckId = null
15
16  function addMessage(type, text) {
17    messages.value.unshift({
18      id: Date.now(),
19      type,
20      text,
21      timestamp: new Date().toLocaleTimeString()
22    })
23    if (messages.value.length > 10) {
24      messages.value.pop()
25    }
26  }
27
28  function connect() {
29    isConnected.value = true
30    connectionTime.value = new Date()
31    reconnectAttempts.value = 0
32    addMessage('success', `Connected to ${serverUrl}`)
33
34    // Simulate latency monitoring
35    intervalId = setInterval(() => {
36      latency.value = Math.floor(Math.random() * 100) + 20
37    }, 2000)
38  }
39
40  function disconnect() {
41    isConnected.value = false
42    disconnectionTime.value = new Date()
43    addMessage('warning', `Disconnected from ${serverUrl}`)
44
45    if (intervalId) {
46      clearInterval(intervalId)
47      intervalId = null
48    }
49  }
50
51  function attemptReconnect() {
52    if (isConnected.value) return
53
54    reconnectAttempts.value++
55    addMessage('info', `Reconnection attempt #${reconnectAttempts.value}`)
56
57    setTimeout(() => {
58      if (reconnectAttempts.value <= 3) {
59        connect()
60        addMessage('success', 'Reconnected successfully!')
61      } else {
62        addMessage('error', 'Max reconnection attempts reached')
63      }
64    }, 1500)
65  }
66
67  // Lifecycle: Mount
68  onMounted(() => {
69    addMessage('info', 'Connection monitor initialized')
70
71    // Auto-connect on mount
72    setTimeout(() => {
73      connect()
74    }, 1000)
75
76    // Simulate random disconnections
77    connectionCheckId = setInterval(() => {
78      if (isConnected.value && Math.random() < 0.1) {
79        addMessage('warning', 'Connection lost - random event')
80        disconnect()
81        setTimeout(attemptReconnect, 2000)
82      }
83    }, 5000)
84  })
85
86  // Lifecycle: Unmount
87  onUnmounted(() => {
88    addMessage('info', 'Connection monitor shutting down')
89    if (intervalId) clearInterval(intervalId)
90    if (connectionCheckId) clearInterval(connectionCheckId)
91    disconnect()
92  })
93
94  return {
95    isConnected,
96    connectionTime,
97    disconnectionTime,
98    reconnectAttempts,
99    latency,
100    messages,
101    connect,
102    disconnect,
103    attemptReconnect
104  }
105}
106
107// Create connection monitors for different servers
108const earthConnection = useConnectionMonitor('Earth Command Center')
109const marsConnection = useConnectionMonitor('Mars Base Alpha')
110const satelliteConnection = useConnectionMonitor('Orbital Satellite')
111</script>
112
113<template>
114  <div class="connection-lab">
115    <div class="header">
116      <h1>Connection Monitor - NOVA LAB</h1>
117      <p class="subtitle">Module 3: Composables with Lifecycle Hooks</p>
118    </div>
119
120    <div class="connections-grid">
121      <!-- Earth Connection -->
122      <div class="connection-card" :class="{ connected: earthConnection.isConnected }">
123        <div class="connection-header">
124          <h3>Earth Command</h3>
125          <div class="status-indicator" :class="{ active: earthConnection.isConnected }">
126            {{ earthConnection.isConnected ? 'ONLINE' : 'OFFLINE' }}
127          </div>
128        </div>
129
130        <div class="connection-stats">
131          <div class="stat-item">
132            <span class="stat-label">Latency:</span>
133            <span class="stat-value" :class="{ good: earthConnection.latency < 50 }">
134              {{ earthConnection.latency }}ms
135            </span>
136          </div>
137          <div class="stat-item">
138            <span class="stat-label">Reconnect Attempts:</span>
139            <span class="stat-value">{{ earthConnection.reconnectAttempts }}</span>
140          </div>
141        </div>
142
143        <div class="connection-times">
144          <div v-if="earthConnection.connectionTime" class="time-info">
145            Connected: {{ earthConnection.connectionTime.toLocaleTimeString() }}
146          </div>
147          <div v-if="earthConnection.disconnectionTime" class="time-info">
148            Disconnected: {{ earthConnection.disconnectionTime.toLocaleTimeString() }}
149          </div>
150        </div>
151
152        <div class="connection-controls">
153          <button
154            @click="earthConnection.connect"
155            :disabled="earthConnection.isConnected"
156            class="btn btn-success"
157          >
158            Connect
159          </button>
160          <button
161            @click="earthConnection.disconnect"
162            :disabled="!earthConnection.isConnected"
163            class="btn btn-danger"
164          >
165            Disconnect
166          </button>
167          <button
168            @click="earthConnection.attemptReconnect"
169            :disabled="earthConnection.isConnected"
170            class="btn btn-warning"
171          >
172            Reconnect
173          </button>
174        </div>
175
176        <div class="messages-log">
177          <div
178            v-for="msg in earthConnection.messages.slice(0, 5)"
179            :key="msg.id"
180            class="log-message"
181            :class="'msg-' + msg.type"
182          >
183            <span class="msg-time">{{ msg.timestamp }}</span>
184            <span class="msg-text">{{ msg.text }}</span>
185          </div>
186        </div>
187      </div>
188
189      <!-- Mars Connection -->
190      <div class="connection-card" :class="{ connected: marsConnection.isConnected }">
191        <div class="connection-header">
192          <h3>Mars Base Alpha</h3>
193          <div class="status-indicator" :class="{ active: marsConnection.isConnected }">
194            {{ marsConnection.isConnected ? 'ONLINE' : 'OFFLINE' }}
195          </div>
196        </div>
197
198        <div class="connection-stats">
199          <div class="stat-item">
200            <span class="stat-label">Latency:</span>
201            <span class="stat-value" :class="{ good: marsConnection.latency < 50 }">
202              {{ marsConnection.latency }}ms
203            </span>
204          </div>
205          <div class="stat-item">
206            <span class="stat-label">Reconnect Attempts:</span>
207            <span class="stat-value">{{ marsConnection.reconnectAttempts }}</span>
208          </div>
209        </div>
210
211        <div class="connection-times">
212          <div v-if="marsConnection.connectionTime" class="time-info">
213            Connected: {{ marsConnection.connectionTime.toLocaleTimeString() }}
214          </div>
215          <div v-if="marsConnection.disconnectionTime" class="time-info">
216            Disconnected: {{ marsConnection.disconnectionTime.toLocaleTimeString() }}
217          </div>
218        </div>
219
220        <div class="connection-controls">
221          <button
222            @click="marsConnection.connect"
223            :disabled="marsConnection.isConnected"
224            class="btn btn-success"
225          >
226            Connect
227          </button>
228          <button
229            @click="marsConnection.disconnect"
230            :disabled="!marsConnection.isConnected"
231            class="btn btn-danger"
232          >
233            Disconnect
234          </button>
235          <button
236            @click="marsConnection.attemptReconnect"
237            :disabled="marsConnection.isConnected"
238            class="btn btn-warning"
239          >
240            Reconnect
241          </button>
242        </div>
243
244        <div class="messages-log">
245          <div
246            v-for="msg in marsConnection.messages.slice(0, 5)"
247            :key="msg.id"
248            class="log-message"
249            :class="'msg-' + msg.type"
250          >
251            <span class="msg-time">{{ msg.timestamp }}</span>
252            <span class="msg-text">{{ msg.text }}</span>
253          </div>
254        </div>
255      </div>
256
257      <!-- Satellite Connection -->
258      <div class="connection-card" :class="{ connected: satelliteConnection.isConnected }">
259        <div class="connection-header">
260          <h3>Orbital Satellite</h3>
261          <div class="status-indicator" :class="{ active: satelliteConnection.isConnected }">
262            {{ satelliteConnection.isConnected ? 'ONLINE' : 'OFFLINE' }}
263          </div>
264        </div>
265
266        <div class="connection-stats">
267          <div class="stat-item">
268            <span class="stat-label">Latency:</span>
269            <span class="stat-value" :class="{ good: satelliteConnection.latency < 50 }">
270              {{ satelliteConnection.latency }}ms
271            </span>
272          </div>
273          <div class="stat-item">
274            <span class="stat-label">Reconnect Attempts:</span>
275            <span class="stat-value">{{ satelliteConnection.reconnectAttempts }}</span>
276          </div>
277        </div>
278
279        <div class="connection-times">
280          <div v-if="satelliteConnection.connectionTime" class="time-info">
281            Connected: {{ satelliteConnection.connectionTime.toLocaleTimeString() }}
282          </div>
283          <div v-if="satelliteConnection.disconnectionTime" class="time-info">
284            Disconnected: {{ satelliteConnection.disconnectionTime.toLocaleTimeString() }}
285          </div>
286        </div>
287
288        <div class="connection-controls">
289          <button
290            @click="satelliteConnection.connect"
291            :disabled="satelliteConnection.isConnected"
292            class="btn btn-success"
293          >
294            Connect
295          </button>
296          <button
297            @click="satelliteConnection.disconnect"
298            :disabled="!satelliteConnection.isConnected"
299            class="btn btn-danger"
300          >
301            Disconnect
302          </button>
303          <button
304            @click="satelliteConnection.attemptReconnect"
305            :disabled="satelliteConnection.isConnected"
306            class="btn btn-warning"
307          >
308            Reconnect
309          </button>
310        </div>
311
312        <div class="messages-log">
313          <div
314            v-for="msg in satelliteConnection.messages.slice(0, 5)"
315            :key="msg.id"
316            class="log-message"
317            :class="'msg-' + msg.type"
318          >
319            <span class="msg-time">{{ msg.timestamp }}</span>
320            <span class="msg-text">{{ msg.text }}</span>
321          </div>
322        </div>
323      </div>
324    </div>
325
326    <div class="info-panel">
327      <h3>Lifecycle Hooks in Composables</h3>
328      <div class="info-content">
329        <p><strong>useConnectionMonitor()</strong> shows how to use lifecycle hooks:</p>
330        <ul>
331          <li><strong>onMounted()</strong> - Auto-connect and monitoring initialization</li>
332          <li><strong>onUnmounted()</strong> - Cleaning up intervals and disconnect</li>
333          <li>Each composable instance has its own lifecycle hooks</li>
334          <li>Automatic resource management (timers, connections)</li>
335          <li>Preventing memory leaks through cleanup in onUnmounted</li>
336        </ul>
337        <div class="code-example">
338          <code>
339function useConnectionMonitor(url) {
340  onMounted(() => {
341    // Setup: auto-connect, start monitoring
342  })
343
344  onUnmounted(() => {
345    // Cleanup: clear timers, disconnect
346  })
347
348  return { isConnected, connect, disconnect }
349}
350          </code>
351        </div>
352      </div>
353    </div>
354  </div>
355</template>
356
357<style scoped>
358.connection-lab {
359  background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
360  min-height: 100vh;
361  padding: 2rem;
362  color: #ffffff;
363  font-family: 'Courier New', monospace;
364}
365
366.header {
367  text-align: center;
368  margin-bottom: 2rem;
369  padding-bottom: 1rem;
370  border-bottom: 2px solid #00ff88;
371}
372
373.header h1 {
374  font-size: 2.5rem;
375  color: #00ff88;
376  text-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
377  margin: 0;
378}
379
380.subtitle {
381  color: #00b4d8;
382  font-size: 1.2rem;
383  margin-top: 0.5rem;
384}
385
386.connections-grid {
387  display: grid;
388  grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
389  gap: 1.5rem;
390  margin-bottom: 2rem;
391}
392
393.connection-card {
394  background: rgba(255, 0, 85, 0.1);
395  border: 2px solid #ff0055;
396  border-radius: 10px;
397  padding: 1.5rem;
398  transition: all 0.3s ease;
399}
400
401.connection-card.connected {
402  background: rgba(0, 255, 136, 0.1);
403  border-color: #00ff88;
404}
405
406.connection-header {
407  display: flex;
408  justify-content: space-between;
409  align-items: center;
410  margin-bottom: 1.5rem;
411}
412
413.connection-header h3 {
414  color: #00ff88;
415  margin: 0;
416  font-size: 1.3rem;
417}
418
419.status-indicator {
420  padding: 0.5rem 1rem;
421  border-radius: 20px;
422  background: rgba(255, 0, 85, 0.3);
423  color: #ff0055;
424  font-weight: bold;
425  font-size: 0.9rem;
426}
427
428.status-indicator.active {
429  background: rgba(0, 255, 136, 0.3);
430  color: #00ff88;
431  animation: pulse 2s infinite;
432}
433
434.connection-stats {
435  display: grid;
436  grid-template-columns: 1fr 1fr;
437  gap: 1rem;
438  margin-bottom: 1rem;
439}
440
441.stat-item {
442  background: rgba(0, 0, 0, 0.3);
443  padding: 1rem;
444  border-radius: 5px;
445  text-align: center;
446}
447
448.stat-label {
449  display: block;
450  color: #00b4d8;
451  font-size: 0.8rem;
452  margin-bottom: 0.5rem;
453}
454
455.stat-value {
456  display: block;
457  color: #ffb400;
458  font-size: 1.5rem;
459  font-weight: bold;
460}
461
462.stat-value.good {
463  color: #00ff88;
464}
465
466.connection-times {
467  background: rgba(0, 0, 0, 0.3);
468  padding: 1rem;
469  border-radius: 5px;
470  margin-bottom: 1rem;
471  min-height: 60px;
472}
473
474.time-info {
475  color: #ffffff;
476  font-size: 0.9rem;
477  margin: 0.25rem 0;
478}
479
480.connection-controls {
481  display: flex;
482  gap: 0.5rem;
483  margin-bottom: 1rem;
484}
485
486.messages-log {
487  background: rgba(0, 0, 0, 0.5);
488  border-radius: 5px;
489  padding: 1rem;
490  max-height: 200px;
491  overflow-y: auto;
492}
493
494.log-message {
495  display: flex;
496  gap: 1rem;
497  padding: 0.5rem;
498  margin: 0.25rem 0;
499  border-left: 3px solid;
500  border-radius: 3px;
501  font-size: 0.85rem;
502}
503
504.msg-success {
505  border-color: #00ff88;
506  background: rgba(0, 255, 136, 0.1);
507}
508
509.msg-warning {
510  border-color: #ffb400;
511  background: rgba(255, 180, 0, 0.1);
512}
513
514.msg-error {
515  border-color: #ff0055;
516  background: rgba(255, 0, 85, 0.1);
517}
518
519.msg-info {
520  border-color: #00b4d8;
521  background: rgba(0, 180, 216, 0.1);
522}
523
524.msg-time {
525  color: #00b4d8;
526  min-width: 80px;
527  font-size: 0.75rem;
528}
529
530.msg-text {
531  color: #ffffff;
532  flex: 1;
533}
534
535.btn {
536  flex: 1;
537  padding: 0.75rem;
538  border: none;
539  border-radius: 5px;
540  font-weight: bold;
541  cursor: pointer;
542  font-family: 'Courier New', monospace;
543  transition: all 0.3s ease;
544  font-size: 0.9rem;
545}
546
547.btn:disabled {
548  opacity: 0.5;
549  cursor: not-allowed;
550}
551
552.btn-success {
553  background: #00ff88;
554  color: #0a0e27;
555}
556
557.btn-danger {
558  background: #ff0055;
559  color: #ffffff;
560}
561
562.btn-warning {
563  background: #ffb400;
564  color: #0a0e27;
565}
566
567.btn:not(:disabled):hover {
568  transform: scale(1.05);
569  box-shadow: 0 5px 15px rgba(0, 255, 136, 0.3);
570}
571
572.info-panel {
573  background: rgba(0, 180, 216, 0.05);
574  border: 2px solid #00b4d8;
575  border-radius: 10px;
576  padding: 2rem;
577}
578
579.info-panel h3 {
580  color: #00ff88;
581  margin-top: 0;
582  margin-bottom: 1.5rem;
583}
584
585.info-content ul {
586  list-style: none;
587  padding: 0;
588}
589
590.info-content li {
591  padding: 0.5rem 0;
592  color: #ffffff;
593  line-height: 1.6;
594}
595
596.code-example {
597  background: rgba(0, 0, 0, 0.5);
598  border-left: 4px solid #00ff88;
599  border-radius: 5px;
600  padding: 1rem;
601  margin-top: 1rem;
602}
603
604.code-example code {
605  color: #00ff88;
606  font-size: 0.9rem;
607  white-space: pre;
608  display: block;
609}
610
611@keyframes pulse {
612  0%, 100% { opacity: 1; }
613  50% { opacity: 0.6; }
614}
615</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 does the VueUse library offer?

  2. 2. What is useLocalStorage from VueUse used for?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Create a responsive component with computed properties isMobile, isTablet, isDesktop.

  • Vertical ordering

    Arrange the steps for installing and using VueUse:

  • Click in order

    Arrange the import of a composable from VueUse:

  • Code editor

    Use useDark and useToggle to create a dark mode toggle with the appropriate CSS class.

  • Horizontal ordering

    Arrange the destructuring of useWindowSize:

Useful articles