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! 👋