Options Object Pattern

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:

  • What happens when you have lots of options? Like, lots?
  • What do you do when you only have a few options?
  • How can you tell when you’re using this pattern the wrong way?

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.

How the pattern works

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 recursively
  deep: true,
  // Limit how many changes we save
  capacity: 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.

Implementing the pattern

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. If the values are required they should likely have

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 directly
  useSomeOtherComposable(otherOptions);
};

A Simple Example

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 window
  const { 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 window
  const { 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 window
useEvent('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 window
  const {
    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 window
useEvent(
  '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.

Advanced Usage

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.

Only a few options

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 object

  onMounted(() => {
    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 button
useEvent(
  '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.

Lots of options

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 window
  const {
    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 window
useEvent(
  'click',
  () => console.log('First time clicking the window!'),
  {
    listener: {
      once: true,
    }
  }
);

Keeping the composable focused

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 window
  const {
    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:

  1. Listening for events
  2. Setting up an interval

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 window
  const {
    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.

🎉 Get 30% off Vue Tips Collection!
Get it now!