My 5 favorite Vue.js composables
The composition API is one of the best things that I love about Vue.js 3.x, and due to the sharable nature of composition API functions that are often called “composables” I wanted to share with you the 5 composables I keep using over and over in my projects and daily work.
I have includes examples in TypeScript to make it easier to follow the values thrown around.
useEventListener
Usually when you add an event listener to elements obtained with refs
or document.querySelector
you need to always clean it up when the component is unmounted. So you end up repetitively doing this over and over:
jsimport { onMounted, onBeforeUnmount } from 'vue';
// in a setup function
function onEscapePressed(e) {
if (e.key === 'Escape') {
// Do something...
}
}
// add the event handler
onMounted(() => {
window.addEventListener('keydown', onEscapePressed);
});
// clean it up
onBeforeUnmount(() => {
window.removeEventListener('keydown', onEscapePressed);
});
This becomes tedious over time, so I wanted to make my life a little bit easier with this composable:
tsexport function useEventListener(
// the target could be reactive ref which adds flexibility
target: Ref<EventTarget | null> | EventTarget,
event: string,
handler: (e: Event) => any
) {
// if its a reactive ref, use a watcher
if (isRef(target)) {
watch(target, (value, oldValue) => {
oldValue?.removeEventListener(event, handler);
value?.addEventListener(event, handler);
});
} else {
// otherwise use the mounted hook
onMounted(() => {
target.addEventListener(event, handler);
});
}
// clean it up
onBeforeUnmount(() => {
unref(target)?.removeEventListener(event, handler);
});
}
With this in hand we can re-write our components to look like this:
jsimport { useEventListener } from '@/composables';
// in a setup function
function onEscapePressed(e) {
if (e.key === 'Escape') {
// Do something...
}
}
useEventListener(window, 'keydown', onEscapePressed);
This cleans up so much from multiple cycles into one-liners in many cases.
useFileDialog
Getting a file from users in pure JavaScript could be tricky. Usually, it involves using an input[type="file"]
either existing in the DOM or created with JavaScript, then it’s a matter of trigger click
and listening for change
on that input to grab the selected files.
Usually along the lines of this:
js// setup an input instance with its attributes
const input = document.createElement('input');
input.type = 'file';
// make sure it is hidden
input.hidden = true;
// wait for the `change` event to pick up the files
input.addEventListener('change', (e) => {
const files = e.target.files;
});
// Add it to the DOM so it becomes clickable
document.body.appendChild(input);
// whenever you want to pick a file
input.click();
Most of the projects I work on involves a lot of file-related features and so to share this “picking” feature between components, I have come up with this composable to clean things up:
tsimport { ref, onMounted, onBeforeUnmount } from 'vue';
interface PickOptions {
accept: string[];
multiple: boolean;
}
export function useFileDialog() {
const inputRef = ref<HTMLInputElement | null>(null);
const files = ref<File[]>([]);
onMounted(() => {
const input = document.createElement('input');
input.type = 'file';
input.hidden = true;
input.className = 'hidden';
document.body.appendChild(input);
inputRef.value = input;
});
// make sure to remove the element if the component is unmounted.
onBeforeUnmount(() => {
inputRef.value?.remove();
});
function openDialog(opts?: Partial<PickOptions>) {
// skip if the input wasn't mounted yet or was removed
if (!inputRef.value) {
files.value = [];
return;
}
if (opts?.accept) {
inputRef.value.accept = opts.accept
.map((ext) => `.${ext}`)
.join(',');
}
inputRef.value.multiple = opts?.multiple ?? false;
// prepare event listener
inputRef.value.onchange = (e) => {
const fileList = (e.target as HTMLInputElement).files;
files.value = fileList ? Array.from(fileList) : [];
// clear the event listener
if (inputRef.value) {
inputRef.value.onchange = null;
}
};
inputRef.value.click();
}
return {
openDialog,
files,
};
}
Now, whenever you want to pick some files it is as easy as this:
tsconst { openDialog, files } = usePickFiles();
// open the file picker
openDialog({
// filter the files by extension
accept: ['jpg', 'jpeg', 'png', 'gif'],
});
// get the current files value:
files.value;
Here it is in action:
useOnClickOutside
This one is extremely useful when you are building menu and dialog components. For those components detecting outside clicks to perform certain actions is part of their UX.
We are going to need to have a click
event handler, this means we need to remove it when the component is unmounted. Remember that we already did that with useEventListener
, right?
tsimport { onMounted, onBeforeUnmount, Ref } from 'vue';
import { useEventListener } from './useEventListener';
export function useOnClickOutside(
rootEl: Ref<HTMLElement | null>,
callback: () => any
) {
// `mousedown` or `mouseup` is better than `click` here because it doesn't bubble up like `click`
// if you've used `click` here, the callback will be run immediatly.
useEventListener(window, 'mouseup', (e: Event) => {
const clickedEl = e.target as HTMLElement;
// skip if the root element contains the clicked element
if (rootEl.value?.contains(clickedEl)) {
return;
}
// otherwise execute the action
callback();
});
}
This is a small example of how you can use the useOnClickOutside
composable
tsimport { ref } from 'vue';
import { useOnClickOutside } from './useOnClickOutside';
// in setup
const isOpen = ref(false);
const containerRef = ref<HTMLElement | null>(null);
onClickOutside(containerRef, () => {
isOpen.value = false;
});
Here it is in action:
useHotkey
Having keyboard shortcuts is very important in modern web apps because it improves power-users productivity.
Since we are dealing with event listeners here, the useEventListener
will make yet another appearance here.
tsinterface HotkeyOptions {
// target element can be a reactive ref
target: Ref<EventTarget> | EventTarget;
shiftKey: boolean;
ctrlKey: boolean;
exact: boolean;
}
export function useHotkey(
key: string,
onKeyPressed: () => any,
opts?: Partial<HotkeyOptions>
) {
// get the target element
const target = opts?.target || window;
useEventListener(target, 'keydown', (e: KeyboardEvent) => {
const options = opts || {};
if (e.key === key && matchesKeyScheme(options, e)) {
e.preventDefault();
onKeyPressed();
}
});
}
function matchesKeyScheme(
opts: Pick<
Partial<HotkeyOptions>,
'shiftKey' | 'ctrlKey' | 'exact'
>,
evt: KeyboardEvent
) {
const ctrlKey = opts.ctrlKey ?? false;
const shiftKey = opts.shiftKey ?? false;
if (opts.exact) {
return ctrlKey === evt.ctrlKey && shiftKey == evt.shiftKey;
}
const satisfiedKeys: boolean[] = [];
satisfiedKeys.push(ctrlKey === evt.ctrlKey);
satisfiedKeys.push(shiftKey === evt.shiftKey);
return satisfiedKeys.every((key) => key);
}
Now you could start adding hotkey interaction to your web application:
tsimport { useHotkey } from './useHotkey';
useHotkey(
'Enter',
() => {
console.log('Enter Pressed!');
},
{
exact: true,
}
);
useHotkey(
'Enter',
() => {
console.log('Cmd Enter pressed!');
},
{
exact: true,
meta: true,
}
);
Here it is in action:
useMedia
Usually, most of your media logic (e.g: screen size, dark mode, motion preference) comes in the form of CSS media queries like this one:
css@supports (display: flex) {
@media screen and (min-width: 900px) {
article {
display: flex;
}
}
}
But sometimes you need that information to drive your JavaScript logic. for example, you could have some JavaScript animations going on but to be more inclusive, you need to know if the user prefers reduced animations or not.
To do that in JavaScript you often use the window.matchMedia
to check whatever media query you need to check:
jsconst motionMatchMedia = window.matchMedia(
'(prefers-reduced-motion)'
);
It is one of the easier hooks to implement, especially if we already use useEventListener
that we already have:
tsexport function useMedia(query: Ref<string> | string) {
const mediaQuery = window.matchMedia(query);
const matches = ref(mediaQuery.matches);
useEventListener(mediaQuery, 'change', (e) => {
matches.value = event.matches;
});
return matches;
}
Now you can perform some media queries and have them as reactive values in your Vue component:
tsimport { useMedia } from './useMedia';
const isReducedMotion = useMedia('(prefers-reduced-motion)');
const isDark = useMedia('(prefers-color-scheme: dark)');
const isLight = useMedia('(prefers-color-scheme: light)');
const isTablet = useMedia('(min-width: 640px)');
And actually, you may use the useMedia
composable function as a basis for other common media queries in your app.
Like this one for responsive screen sizes:
tsexport function useScreenSize() {
const isMobile = useMedia('(min-width: 640px)');
const isLaptop = useMedia('(min-width: 1024px)');
const isDesktop = useMedia('(min-width:1280px)');
return computed(() => {
if (isDesktop.value) {
return 'desktop';
}
if (isLaptop.value) {
return 'laptop';
}
if (isMobile.value) {
return 'mobile';
}
return 'unknown';
});
}
This example is less impressive than the previous ones, but here it is in action.
Try emulating some of these media queries:
If you are on Chrome: Devtools > More tools > Rendering
Conclusion
You’ve seen how we can use the composition API to create simple but powerful sharable pieces of logic that can be used a lot in modern web applications. This is the promise of the composition API.
Many of those composable functions exist in some form or another in open-source libraries like vueuse, so grab them whenever you need them in your applications and they cover more edges cases than we did here.
Thanks for Reading! 👋