Published on

Building an Event Driven useToggleEvents Composable With Vue and TypeScript

Overview

I've recently noticed a recurring design pattern in my projects. It's a simple one, however extremely useful. A Boolean value changes, and the UI needs to reflect that change. Not a very challenging problem to solve, sure. But what happens when you find yourself repeating this design pattern multiple times throughout an app? Maybe even within the same component!

Picard in chair

I'll abstract away this tedious and repetitive code, and build a composable to make managing boolean values and their corresponding event listeners as simple as calling a function.

Table of Contents

The Requirements

I'll first state the intention of this composable so it's crystal clear what I'm building.

This composable should return an API that allows the toggling of a Boolean value, subscribe to a Boolean values' changes, and to have certain callbacks triggered when this Boolean value changes. It should also allow specification of if a callback should be notified of updates in perpetuity, or only the first time the Boolean updates. This is helpful when we only want to know when a boolean changes from false to true.

Ok that's a start. So maybe the composable will return an Array (which allows us to rename these values later on with destructuring) with the following values: return [registerTrueCallback, registerFalseCallback, toggleBooleanValue, booleanValue]

Let's get started.

Scaffolding The Composable

Okay, let's start by scaffolding the useToggleEvents composable.

// useToggleEvents.ts
import { ref, watchEffect, onUnmounted, type Ref } from 'vue'

type Callback = (...args: any[]) => void
type Callable = (...args: any[]) => Function

interface Subscription {
  callback: Callback
  once: boolean
}

export default function useToggleEvents(
  initialValue: boolean
): [Callable, Callable, () => void, Ref<boolean>] {
  const booleanValue = ref(initialValue)
  const callbacksTrue: Subscription[] = []
  const callbacksFalse: Subscription[] = []

  const register = (callbacksArray: Subscription[], callback: Callback, once = false) => {}

  const registerTrueCallback = (callback: Callback, options = { once: false }) => {}

  const registerFalseCallback = (callback: Callback, options = { once: false }) => {}

  const toggleBooleanValue = () => {
    booleanValue.value = !booleanValue.value
  }

  return [registerTrueCallback, registerFalseCallback, toggleBooleanValue, booleanValue]
}

So the only input here is initialValue: the starting Boolean value, with its default value set to false. Notice how I declare booleanValue as a ref (line 15) with its value set to the initialValue input. This will allow us to reactively watch changes on booleanValue.

I've declared two arrays to store subscriptions for each of the event types (16-17) . I then scaffold the functions needed to register callbacks for the subscriptions (19-23). Both of these functions will accept a callback, and an options object that declares whether the callback should fire only once. The register function (19) will control adding subscriptions to the correct callbacks array while the registerTrueCallback and registerFalseCallback functions will be the public functions to subscribe to events.

Nice. The inputs and outputs are ready to go. Time to build the logic for the register functions.

Create The Register Function Logic

A break down of the functions to simplify building.

Register

  • Push the subscriptions onto the correct callbacks array.
  • Return a function that will unsubscribe a callback from events.

RegisterTrueCallback and RegisterFalseCallback Functions

  • Accept a callback and options, and call the subscribe supplying the correct callbacks array to push the subscription onto.

Sounds easy enough right?

const register = (callbacks: Subscription[], callback: Callback, once = false) => {
  const subscription = { callback, once }
  callbacks.push(subscription)

  return () => {
    const index = callbacks.indexOf(subscription)
    if (index !== -1) {
      callbacks.splice(index, 1)
    }
  }
}

const registerTrueCallback = (callback: Callback, options = { once: false }) => {
  return subscribe(callbacksTrue, callback, options.once)
}

const registerFalseCallback = (callback: Callback, options = { once: false }) => {
  return subscribe(callbacksFalse, callback, options.once)
}

Awesome, almost there! All I'm doing here is pushing the callbacks into the relevant callbacks array. The register function also returns a handy function that will allow it's callers to unsubscribe from events. Next up, watching reactive changes on the booleanValue variable, so I can trigger the correct callbacks!

Watching Reactive Changes and Triggering The Callbacks

Vue makes it really simple to watch updates on the booleanValue variable. I'll use the watchEffect hook from the Reactivity Api. WatchEffect accepts a function to call whenever one of its dependencies changes. Read more about it here.

The goal here is to watch for changes on the booleanValue variable, and trigger the correct callbacks upon these changes. Let's create a triggerCallbacks function to handle triggering callbacks.

TriggerCallbacks

  • Given a true/false value, loop through the correct callbacks array, and trigger each callback.
  • If a callback should only be run once, remove the callback from the array to prevent the callback from being called again.
const triggerCallbacks = (value: boolean, callbacks: Subscription[]) => {
  callbacks.forEach((subscription) => {
    subscription.callback(value)
    if (subscription.once) {
      const index = callbacks.indexOf(subscription)
      if (index !== -1) {
        callbacks.splice(index, 1)
      }
    }
  })
}

Awesome! Now I only need to watch for changes on the booleanValue variable and call this triggerCallbacks function. I'll check the value of booleanValue to set which callbacks array we'll be triggering. I'll also use the unmounted hook to cleanup the callback arrays when a component gets unmounted.

watchEffect(() => {
  const callbacks = booleanValue.value ? callbacksTrue : callbacksFalse
  triggerCallbacks(booleanValue.value, callbacks)
})

unMounted(() => {
  callbacksTrue.length = 0
  callbacksFalse.length = 0
})

The Completed Composable

That ought to do it! Here's the entire composable.

import { ref, watchEffect, onUnmounted, type Ref } from 'vue'

type Callback = (...args: any[]) => void
type Callable = (...args: any[]) => Function

interface Subscription {
  callback: Callback
  once: boolean
}

export default function useToggleEvents(
  initialValue: boolean = false
): [Callable, Callable, () => void, Ref<boolean>] {
  const booleanValue = ref(initialValue)
  const callbacksTrue: Subscription[] = []
  const callbacksFalse: Subscription[] = []

  const register = (callbacks: Subscription[], callback: Callback, once = false) => {
    const subscription = { callback, once }
    callbacks.push(subscription)

    return () => {
      const index = callbacks.indexOf(subscription)
      if (index !== -1) {
        callbacks.splice(index, 1)
      }
    }
  }

  const registerTrueCallback = (callback: Callback, options = { once: false }) => {
    return register(callbacksTrue, callback, options.once)
  }

  const registerFalseCallback = (callback: Callback, options = { once: false }) => {
    return register(callbacksFalse, callback, options.once)
  }

  const toggleBooleanValue = () => {
    booleanValue.value = !booleanValue.value
  }

  const triggerCallbacks = (value: boolean, callbacks: Subscription[]) => {
    callbacks.forEach((subscription) => {
      subscription.callback(value)
      if (subscription.once) {
        const index = callbacks.indexOf(subscription)
        if (index !== -1) {
          callbacks.splice(index, 1)
        }
      }
    })
  }

  watchEffect(() => {
    const callbacks = booleanValue.value ? callbacksTrue : callbacksFalse
    triggerCallbacks(booleanValue.value, callbacks)
  })

  onUnmounted(() => {
    callbacksTrue.length = 0
    callbacksFalse.length = 0
  })

  return [registerTrueCallback, registerFalseCallback, toggleBooleanValue, booleanValue]
}

In this post, I set up some unit tests and put this composable to work building a dark mode toggle. Check it out!

View the demo and documentation for this composable here.

Enjoyed this Article? Sign up for my newsletter already!