There are many patterns you can use to improve your composables.
Using an object to pass parameters in is a very useful one that’s used all over the place — just take a look at the source of VueUse.
But while this pattern may seem straightforward at first glance, there are some things to consider when implementing it:
In this article, we’ll explore the Options Object Pattern. We’ll cover the basics of implementation, then move on to advanced use cases and weighing the tradeoffs that come with using it.
In order to make our code more reusable, we need it to cover a wide range of cases.
We do this by passing in an object that contains all of the configuration options for how we want the composable to behave:
const state = ref({ email: '' });const { history, undo, redo } = useRefHistory(state, {// Track history recursivelydeep: true,// Limit how many changes we savecapacity: 10,});
We use an object here instead of a long list of parameters:
const { history, undo, redo } = useRefHistory(state, true, 10));
Using an options object instead of parameters gives us several benefits.
First, it’s self-documenting. We have the name of the parameter right beside the value, so we never forget what each value is doing.
We can also create a type for the entire options object:
export type RefHistoryOptions {deep?: boolean;capacity?: number;};export type RefHistoryReturn {history: Ref;undo: () => void;redo: () => void;};export function useRefHistory(ref: Ref,options: RefHistoryOptions): RefHistoryReturn {};
Second, we don’t need to worry about ordering or unused options. The more potential edge cases we cover with a composable, the more options we’ll have. But we usually only need to worry about a couple of them at one time — they’re all optional.
Third, it’s much easier to add new options. Because the order doesn’t matter and none of the options are required, adding a new capability to our composable won’t break anything. We simply add it to the list of possible options and carry on.
The pattern doesn’t require a lot of work to implement, either:
export function useRefHistory(ref, options) {const {deep = false,capacity = Infinity,} = options;// ...};
First, we pass in the options object as the last parameter. This makes it possible to have the options object itself as an optional parameter.
The required params come first. Typically, there will only be one or two. More parameters is a code smell, and likely means that your composable is trying to do too much.
The required parameter (or parameters) is very often a Ref
, or a MaybeRef
if we’re also implementing the Flexible Arguments Pattern.
We then access the options by destructuring.
Doing this gives us a really clean and readable way of providing defaults. Remember, these are options so they should all have defaults.
This helps to clarify what options are being used in this composable. It’s not uncommon for one composable to use another composable, and in that case some of the options are simply passed along to the inner composable:
export function useRefHistory(ref, options) {const {deep = false,capacity = Infinity,...otherOptions,} = options;// Pass along some options we're not using directlyuseSomeOtherComposable(otherOptions);};
Let’s create a useEvent
composable that will make it easier to add event listeners.
We’ll use the EventTarget.addEventListener
method, which require the event
and handler
. These are the first two required parameters:
export function useEvent(event, handler) {};
But we also need to know which element to target. Since we can default to the window
, we’ll make this our first option:
export function useEvent(event, handler, options) {// Default to targeting the windowconst { target = window } = options;};
Then we’ll add in onMounted
and onBeforeUnmount
hooks to setup and clean up our event:
import { onMounted, onBeforeUnmount } from 'vue';export function useEvent(event, handler, options) {// Default to targeting the windowconst { target = window } = options;onMounted(() => {target.addEventListener(event, handler);});onBeforeUnmount(() => {target.removeEventListener(event, handler);});};
We can use the composable like this:
import useEvent from '~/composables/useEvent.js';// Triggers anytime you click in the windowuseEvent('click', () => console.log('You clicked the window!'));
The addEventListener
method can also take extra options, so let’s add support for that, too:
import { onMounted, onBeforeUnmount } from 'vue';export function useEvent(event, handler, options) {// Default to targeting the windowconst {target = window,...listenerOptions} = options;onMounted(() => {target.addEventListener(event, handler, listenerOptions);});onBeforeUnmount(() => {target.removeEventListener(event, handler, listenerOptions);});};
We keep listenerOptions
as a pass-through, so we’re not coupling our composable with the addEventListener
method. Beyond hooking up the event, we don’t really care how it works, so there’s no point in interfering here.
Now we can take advantage of those extra options:
import useEvent from '~/composables/useEvent.js';// Triggers only the first time you click in the windowuseEvent('click',() => console.log('First time clicking the window!'),{once: true,});
This is a pretty basic composable, but by using the Options Object Pattern it’s easily configurable and extendable to cover a wide swath of use cases.
Most of software development is handling edge cases, so let’s take a look at some edge cases and things to keep in mind when using this pattern.
If you only have one or two options for you composable, using an entire options object may not really be worth it. Instead, you can simply use an optional parameter or two.
With our useEvent
composable that would look like this:
import { onMounted, onBeforeUnmount } from 'vue';export function useEvent(event, handler, target = window) {// No point in having an options objectonMounted(() => {target.addEventListener(event, handler);});onBeforeUnmount(() => {target.removeEventListener(event, handler);});};
If we need to target a different element, like a button, we can use it like this:
import useEvent from '~/composables/useEvent.js';// Triggers anytime you click the buttonuseEvent('click',() => console.log('You clicked the button!'),buttonElement);
But, if we want to add more options in the future, we break this usage because we’ve changed the function signature:
before: useEvent(event, handler, target)after: useEvent(event, handler, options)
It’s a design choice you’ll have to make. Starting with a small options object prevents breaking, but adds a small amount of complexity to your composable.
Since all of the options are optional, the sheer number of options is never really a problem when it comes to using a composable. Further, we can organize the options into sub objects if we really felt the need.
With the useEvent
composable we can group all the listenerOptions
into their own object to help organize things:
import { onMounted, onBeforeUnmount } from 'vue';export function useEvent = (event, handler, options) => {// Default to targeting the windowconst {target = window,listener,} = options;onMounted(() => {target.addEventListener(event, handler, listener);});onBeforeUnmount(() => {target.removeEventListener(event, handler, listener);});};
The usage now becomes this:
import useEvent from '~/composables/useEvent.js';// Triggers only the first time you click in the windowuseEvent('click',() => console.log('First time clicking the window!'),{listener: {once: true,}});
Although the total number of options (and required params) isn’t itself a problem, it is an indication that the design isn’t quite as good as it could be.
Chances are that your composable is trying to do more than one thing, and should instead be separated into several composables. The point of composables is that they each do one specific thing really well, and can be composed together to produce more complex functionality.
Imagine that our useEvent
composable looked like this instead:
import { ref, onMounted, onBeforeUnmount } from 'vue';export function useEvent(event, handler, interval, options) => {// Default to targeting the windowconst {target = window,...listenerOptions} = options;const startInterval = () => {setInterval(handler, interval);};onMounted(() => {target.addEventListener(event, startInterval, listenerOptions);});onBeforeUnmount(() => {target.removeEventListener(event, startInterval, listenerOptions);});};
We’d use it like this. As soon as the button is clicked, we’ll log to the console every second:
import useEvent from '~/composables/useEvent.js';useEvent('click',() => console.log('Logging every second'),1000,{target: buttonElement,});
We can see that it’s doing two separate things:
Instead of including the interval functionality in our useEvent
composable, it makes more sense to break it out into a second composable:
export function useInterval(callback, options) {const { interval = 1000 } = options;const intervalId = setInterval(callback, interval);return () => clearInterval(intervalId);};
Our useEvent
composable goes back to what we had before, and now we can compose the two together to get the desired effect:
import { onMounted, onBeforeUnmount } from 'vue';export function useEvent(event, handler, options) {// Default to targeting the windowconst {target = window,...listenerOptions} = options;onMounted(() => {target.addEventListener(event, handler, listenerOptions);});onBeforeUnmount(() => {target.removeEventListener(event, handler, listenerOptions);});};
import useEvent from '~/composables/useEvent.js';import useInterval from '~/composables/useInterval.js';useEvent('click',() => useInterval(() => console.log('Logging every second')),{target: buttonElement,});
When we click on the buttonElement
, we call useInterval
to set up the interval that will log to the console every second.