Andrew Timberlake Andrew Timberlake

Hi, I’m Andrew, a programmer and entrepreneur from South Africa,
building Mailcast for taking control of your email.
Thanks for visiting and reading.


Multiple hooks per element in Phoenix LiveView

Currently, Phoenix LiveView only supports one hook per element. I recently ran into that limitation when I wanted to add functionality to a table that already had a hook on it. I came across this forum post Liveview Handling multiple phx-hook on same DOM node with a suggestion by Chris to delegate out to other hooks. Sounded simple enough, so I thought I'd give it a try.

A simple delegation example

You must implement and call every lifecycle callback that the delegated hook implements.
The delegated hook must not have any of its “own” functions accessed through this.

import MyOtherHook from './my_other_hook';

const Hooks = {
  MultiHook: {
    mounted() {
      MyOtherHook.mounted.call(this);
    },
    beforeUpdate() {
      MyOtherHook.beforeUpdate.call(this);
    },
    updated() {
      MyOtherHook.updated.call(this);
    },
    destroyed() {
      MyOtherHook.destroyed.call(this);
    },
    disconnected() {
      MyOtherHook.disconnected.call(this);
    },
    reconnected() {
      MyOtherHook.reconnected.call(this);
    },
  }
}

How LiveView creates hooks

When LiveView creates a hook, it takes your object of functions (the hook) and attaches them to an instance of an internal class named ViewHook along with some other useful functions. This enables your simple object to reference itself through this.

const Hooks = {
  MyHook: {
    mounted() {
      // this.el <- How does el appear on this if this isn’t a class?
      // I can also call this.myFunction()
    },
    myFunction() {
      // Do something
    }
  }
}

If you want to dig into the code, you can have a look at view_hook.js and see how the callbacks are attached to the ViewHook class in the constructor.

The ViewHook class isn’t exposed to the global scope, so you can’t access it from your hooks. But, even if we could, it has an internal HOOK_ID that is placed on the DOM element which we wouldn’t want to override.

To solve all these problems, and to make it easier to delegate out to other hooks, I created a DelegateHook class that does all the work of ensuring the delegated hook has all it’s lifestyle callbacks called as well as other functions expected to work within a hook.

To use it is as simple as adding new DelegateHook(this, MyOtherHook) to the mount callback of an existing hook.

import DelegateHook from './delegate_hook';
import MyOtherHook from './my_other_hook';
import AnotherHook from './another_hook';

const Hooks = {
  MultiHook: {
    mounted() {
      DelegateHook.delegate(this, MyOtherHook);
      DelegateHook.delegate(this, AnotherHook);
      // implement mounted as needed.
    },
    // implement other callbacks as needed.
  }
}

let liveSocket = new LiveSocket('/live', Socket, {
  params: { _csrf_token: csrfToken },
  hooks: Hooks
});
<div phx-hook="MultiHook">
  <!-- content -->
</div>

The DelegateHook class

Here is the complete code for the DelegateHook class.
While I’ve done a fair amount of testing and I’m using it in one of my projects, I can’t guarantee this works for all hooks. If you find any issues, or a better way to do this, please let me know.

// Hook callbacks
const liveViewLifecycleCallbacks = [
  'mounted',
  'beforeUpdate',
  'updated',
  'destroyed',
  'disconnected',
  'reconnected',
];

/*
  This class can be used to connect multiple hooks to a single element.

  Usage:

  Hooks = {
    MultiHook: {
      mounted() {
        DelegateHook.delegate(this, MyOtherHook);
        DelegateHook.delegate(this, AnotherHook);
        // implement mounted as needed.
      },
      // implement other callbacks as needed.
    }
  }
*/
export default class DelegateHook {
  static delegate(hook, callbacks) {
    return new DelegateHook(hook, callbacks);
  }

  constructor(hook, callbacks) {
    // Attach the parent hook element and liveSocket to the delegate hook
    this.el = hook.el;
    this.liveSocket = hook.liveSocket;

    // Override the parent hook callbacks with functions that also call the delegate hook
    for (let key of liveViewLifecycleCallbacks) {
      const callback = callbacks[key];
      if (callback) {
        const hookCallback = hook[key];
        this[key] = callback;
        hook[key] = (...args) => {
          hookCallback?.(...args);
          this[key].call(hook, ...args);
        };
      }

      // All other callbacks are attached to the delegate so that `this` works correctly within the delegate hook
      Object.keys(callbacks)
        .filter((key) => !liveViewLifecycleCallbacks.includes(key))
        .forEach((key) => {
          this[key] = callbacks[key];
        });
    }

    // Call the mounted callback on the delegate hook
    this.mounted?.();
  }
}

27 Mar 2025

I’m available for hire, whether you need an hour to help you work through a particular problem, or help with a larger project.