Some components require a lot of wiring to get working correctly, the wiring being Passing props, listening to events. However, some components can get out of hand and produce a lot of “noise”. In this article you will come to understand what component noise is and how to reduce it with the composition API.

Heads up

Do not get disoriented if you see me using script then template blocks. You can have them in any order you prefer, but we mostly focus on script issues so it makes sense to show it first.

A Modal walked into the bar

Let’s say you want to create a modal component that will be used to display some kind of confirmation dialog, you may want to use it to confirm deleting stuff.

At first glance, you may create a modal component that looks something like this:

vue<script setup lang="ts">
const props = defineProps<{
  visible: boolean;
}>();

const emit = defineEmits<{
  (e: 'close'): void;
  (e: 'confirmed'): void;
}>();

function onClose() {
  emit('close');
}

function onConfirm() {
  emit('confirmed');
}
</script>

<template>
  <div
    v-if="visible"
    class="modal-backdrop"
    @click.self="onClose"
  >
    <div class="modal">
      <slot />

      <div class="modal__footer">
        <button type="button" class="confirm" @click="onConfirm">
          Confirm
        </button>

        <button type="button" class="cancel" @click="onClose">
          Cancel
        </button>
      </div>
    </div>
  </div>
</template>

You may want to externalize the visible prop and move the v-if to the parent component, but I prefer to keep it internal because this is within the Modal’s component purview and responsibility.

So using this component usually looks something like this:

vue<script setup lang="ts">
import { ref } from 'vue';
import ModalDialog from '@/components/ModalDialog.vue';

const isOpen = ref(false);
</script>

<template>
  <ModalDialog :visible="isOpen" @close="isOpen = false">
    Are you sure you want to do this?
  </ModalDialog>
</template>

This is simple but it leaves something to be desired. We need to setup a value binding and an event listener. The issue here is if you forget to listen for the close event or forget to set isOpen back to false the modal won’t close.

This the first problem we have, and the first noise. Such components cannot control their state freely as they require the parent to show them but they can’t close/hide themselves without telling the parent and the parent doing it properly.

We could make it slightly better with v-model support so let’s add that:

vue<script setup lang="ts">
const props = defineProps<{
  modelValue: boolean;
}>();

const emit = defineEmits<{
  (e: 'update:modelValue', value: boolean): void;
  (e: 'confirmed'): void;
}>();

function onClose() {
  emit('update:modelValue', false);
}
</script>

<template>
  <div
    v-if="modelValue"
    class="modal-backdrop"
    @click.self="onClose"
  >
    <div class="modal">
      <slot />
    </div>
  </div>
</template>

Now we can use v-model with it, it feels much nicer as we have to write even less code than before:

vue<script setup lang="ts">
import { ref } from 'vue';
import ModalDialog from '@/components/ModalDialog.vue';

const isOpen = ref(false);

// Opens the modal
isOpen.value = true;

// Closes the modal
isOpen.value = false;
</script>

<template>
  <ModalDialog v-model="isOpen">
    Are you sure you want to do this?
  </ModalDialog>
</template>

Now the component somewhat owns the close behavior, it can close itself at any time for any reason and the parent can open it or close at any given time. It can’t get better than this, no?

The noise

So the previous example feels nice and simple. However, it falls short once you have a slightly complicated scenario. Here is a common one:

You have a list of items, you want to mark an item for deletion and want the modal to confirm that action.

If you don’t see how that complicates things, I have a couple of issues that irk me whenever I get into this situation:

  • The visibility state is a boolean, so now I need to track both a visibility state and the item to mark for deletion
  • We need to sync both states whenever the modal opens/closes

Here is a quick example to show how these two issues make things a little annoying:

vue<script setup lang="ts">
import { ref } from 'vue';
import ModalDialog from '@/components/ModalDialog.vue';

interface Item {
  id: number;
  name: string;
}

const items = ref<Item[]>([
  //...
]);

const itemToDelete = ref<Item>();
const isOpen = ref(false);

function onClose() {
  itemToDelete.value = undefined;
  isOpen.value = false;
}

function onDeleteClick(item: Item) {
  itemToDelete.value = item;
  isOpen.value = true;
}

function onConfirmed() {
  if (!itemToDelete.value) {
    onClose();
    return;
  }

  const idx = items.value.findIndex((i) => i.id === item.id);
  items.value.splice(idx, 1);
  console.log('Deleted', item);
  onClose();
}
</script>

<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"
      @click="onDeleteClick(item)"
    >
      {{ item.name }}
    </li>
  </ul>

  <ModalDialog
    :model-value="isOpen"
    @update:modelValue="onClose"
    @confirmed="onConfirmed"
  >
    Are you sure you want to delete {{ itemToDelete?.name }}?
  </ModalDialog>
</template>

To me, this is getting a little disgusting. Can you see how much wiring do we have to set up just to delete an item from the list?

You have a couple of states to worry about (isOpen and itemToDelete), and need to keep them synced at all times. Then we have a bunch (three) of events to handle and we are back to having both a value binding and an event handler, so back to square one in that regard.

Another issue is the modal update:modelValue doesn’t make any sense, in theory it could emit true so what happens then? We can’t really handle that case, so its not really updating a model value, the item to delete is actually the state that decides the visibility.

All that required wiring makes the component “noisy” to me, and that almost always means that the component is very brittle and can fail if you don’t wire something correctly. If you miss any of the two states or the three handlers, it no longer works.

We can make this slightly better making isOpen depend on itemToDelete so we can use v-model again. And some typing changes to give us some leeway.

vue<script setup lang="ts">
import { ref, computed } from 'vue';
import ModalDialog from '@/components/ModalDialog.vue';

interface Item {
  id: number;
  name: string;
}

const items = ref<Item[]>([
  //...
]);

const itemToDelete = ref<Item>();
const isOpen = computed({
  get() {
    return !!itemToDelete.value;
  },
  set(value) {
    // We still don't know how to handle this if `value` is true.
    itemToDelete.value = value ? undefined : undefined;
  },
});

function onClose() {
  itemToDelete.value = undefined;
}

function onDeleteClick(item: Item) {
  itemToDelete.value = item;
}

function onConfirmed() {
  if (!itemToDelete.value) {
    return;
  }

  const idx = items.value.findIndex((i) => i.id === item.id);
  items.value.splice(idx, 1);
  console.log('Deleted', item);
}
</script>

<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"
      @click="onDeleteClick(item)"
    >
      {{ item.name }}
    </li>
  </ul>

  <ModalDialog v-model="isOpen" @confirmed="onConfirmed">
    Are you sure you want to delete {{ itemToDelete?.name }}?
  </ModalDialog>
</template>

This is slightly better, we managed to reduce the number of states we need to keep track of. However we still have that annoying isOpen.value = true issue. We really cannot handle that case.

Furthermore, if you are building some confirm-heavy UI, like a dashboard or CRUD of sorts then you will need to do all that stuff just to use the ModalDialog component and the API isn’t perfect and there is a lot of room for mistakes.

Here is a little trick I do to figure out how much noisy they are. Describe what is going on in a “dev story” format. Here is an example for a dev story for the previous snippet:

“When I select an item for deletion, mark the item for deletion and show a modal to confirm it. If it is cancelled then unmark the item and if it is confirmed then remove the item and close the modal”

This feels a little verbose, almost as verbose as the code you wrote for this thing to work.

This may not be an issue at all, but I can’t help but think of a better way to do things, something to mute this noise away. A more technical description of the issue is that the modal component is leaking too much and delegating too much.

Using composition API to create components

I have covered this in a different topic here. But the takeaways are:

  • We can construct components on the fly in the setup function and use it in our template.
  • We can create component wrappers that act as generic components

This helps in a few ways ways:

  • Predefine some Props
  • Expose clearer APIs with explicit behavior
  • Hide some complexity away

First, let’s see how can we convert the modal dialog into a composable.

We can use the initial version of our modal dialog as it’s API is more clear in terms of prop names and events. We won’t need the v-model support anymore.

Usually, I organize my composable functions/APIs in a features/{name} fashion. So let’s create a features/modal.ts file:

tsimport ModalDialog from '@/components/ModalDialog.vue';

function useModalDialog() {
  // TODO:
}

Now, let us have a thinking moment about how we want it to work. We know at least that opening/closing menus isn’t just about a boolean state anymore. It can be really any kind of data, we can think of this as the modal dialog opens in a contextual data of sorts, like the item we want to delete.

So building it as a generic makes a lot of sense:

tsimport ModalDialog from '@/components/ModalDialog.vue';

function useModalDialog<TData = unknown>() {
  // TODO:
}

We typed TData as unknown by default because we don’t really know what kind of data it is, and there are no restrictions we can assume, this component is really dumb in that regard and that is great, it means it is very flexible. You can use any if you prefer but unknown is more type safe.

Next step is building the logic. We want the consumer to be able to open the modal with contextual data and close it.

There are a few ways to go about this, so let’s take it one step at a time.

First let’s handle the state. We will need to create a wrapper component and internalize the showing and hiding logic.

tsimport { ref, defineComponent, h } from 'vue';
import ModalDialog from '@/components/ModalDialog.vue';

function useModalDialog<TData = unknown>() {
  // The contextual data
  const data = ref<TData>();

  function onClose() {
    data.value = undefined;
  }

  const DialogComponent = defineComponent({
    inheritAttrs: false,
    setup(_, { slots, emit }) {
      function onConfirmed() {
        if (data.value !== undefined) {
          emit('confirmed', data.value);
        }
      }

      return () =>
        h(ModalDialog, {
          onClose,
          onConfirmed,
          visible: data.value !== undefined,
        });
    },
  });

  return {
    DialogComponent,
  };
}

However, we have no way to open it now or close it. So let’s add that, we can expose really clear show and hide functions:

tsimport { ref, VNode, defineComponent, h } from 'vue';
import ModalDialog from '@/components/ModalDialog.vue';

function useModalDialog<TData = unknown>() {
  // ...
  function show(value: TData) {
    data.value = value;
  }

  function hide() {
    data.value = undefined;
  }

  const DialogComponent = defineComponent({
    // ..
  });

  return {
    DialogComponent,
    show,
    hide,
  };
}

Let’s see how well this works in a consuming component. Let’s use the previous delete item example:

vue<script setup lang="ts">
import { ref } from 'vue';
import { useModalDialog } from '@/features/modal';

interface Item {
  id: number;
  name: string;
}

const items = ref<Item[]>([
  //...
]);

const {
  show: onDeleteClick,
  DialogComponent: DeleteItemDialog,
  hide,
} = useModalDialog<Item>();

function onConfirm(item: Item) {
  const idx = items.value.findIndex((i) => i.id === item.id);
  items.value.splice(idx, 1);
  console.log('Deleted', item);
  hide();
}
</script>

<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"
      @click="onDeleteClick(item)"
    >
      {{ item.name }}
    </li>
  </ul>

  <DeleteItemDialog @confirmed="onConfirm">
    <!-- ❌ Oops, we no longer have access to that -->
    Are you sure you want to delete {{ itemToDelete }}?
  </DeleteItemDialog>
</template>

We are very close to what we need, the component is much cleaner already. We’ve removed all listeners but one and we no longer have any state to worry about.

But we no longer have access to itemToDelete state anymore, which we could need in a lot of cases like this one.

Now we could in theory pass the ref ourselves and have the useModalDialog not internalize that state, something like this:

tsconst itemToDelete = ref<Item>();
const { show } = useModalDialog(itemToDelete);

This could work really well, however we can solve it by using slots here. We can have the ModalDialog expose the contextual data on its default slot, giving us access to it:

tsimport { ref, VNode, defineComponent, h } from 'vue';
import ModalDialog from '@/components/ModalDialog.vue';

export function useModalDialog<TData = unknown>() {
  // ...
  const DialogComponent = defineComponent({
    inheritAttrs: false,
    setup(_, { slots, emit }) {
      //...
      return () =>
        h(
          ModalDialog,
          {
            onClose: hide,
            onConfirmed,
            visible: data.value !== undefined,
          },
          {
            default: () => slots.default?.({ data: data.value }),
          },
        );
    },
  });

  return {
    DialogComponent:
      DialogComponent as typeof DialogComponent & {
        // we augment the wrapper type with a constructor type that overrides/adds
        // the slots type information by adding a `$slots` object with slot functions defined as properties
        new (): {
          $emit: {
            (e: 'confirmed', data: TData): void;
          };
          $slots: {
            default: (arg: { data: TData }) => VNode[];
          };
        };
      },
    show,
    hide,
  };
}

Aside from the weird syntax for giving the slot types to the component, we just added data on the component slot props. I admit it feels a little complicated and out of no where, but AFAIK there not a lot of ways you can define slots manually without using an SFC.

Here is how it is used now after the changes:

vue<script setup lang="ts">
import { ref } from 'vue';
import { useModalDialog } from '@/features/modal';

interface Item {
  id: number;
  name: string;
}

const items: Item[] = ref([
  //...
]);

const {
  show: onDeleteClick,
  DialogComponent: DeleteItemDialog,
  hide,
} = useModalDialog<Item>();

function onConfirm(item: Item) {
  const idx = items.value.findIndex((i) => i.id === item.id);
  items.value.splice(idx, 1);
  console.log('Deleted', item);
  hide();
}
</script>

<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"
      @click="onDeleteClick(item)"
    >
      {{ item.name }}
    </li>
  </ul>

  <DeleteItemDialog
    v-slot="{ data: itemToDelete }"
    @confirmed="onConfirm"
  >
    Are you sure you want to delete {{ itemToDelete.name }}?
  </DeleteItemDialog>
</template>

Now to me, this is really simple and clean and doesn’t have any room for errors. You no longer concerned in the parent component about how the Modal component works. Let’s write another dev story after all of that.

”When I click an item, open a dialog for it, and when the action is confirmed let me know so I can delete the item”.

The code to me reads like that. I may have cheated a little but the story itself is more explicit than before. It is no longer a ModalDialog component, it is a DeleteItemDialog and that specialization allows you to omit a lot of details away.

Here it is in action:

What’s even better here is you can re-use useModalDialog to create as many dialogs as you need in the same component without complicating or increasing the code much:

ts// Multiple modals
const {
  show: onDeleteClick,
  DialogComponent: DeleteItemDialog,
} = useModalDialog<Item>();

const {
  show: onUpdateClick,
  DialogComponent: UpdateItemDialog,
} = useModalDialog<Item>();

Further improvements

Where do you go from here? Well, I have a couple of improvements that could be worthwhile.

Injections, anybody?

You could create some sort of a “shared” modal dialog that many components can reach out to and use. Maybe with the provide/inject API:

js// In a parent page or component
const { DialogComponent, ...modalApi } = useModalDialog();

// make modal api available to child components
provide('modal', modalApi);

// Somewhere else in a child component:
const modal = inject('modal');

modal.show(data);

Heads up

Perhaps you can bake the injection in the previous example inside the useModalDialog so that it injects that modal context and any child component can inject and show/hide the modal.

This makes it handy to use one modal for a repeated list of complex items if you need to make each item component show the dialog.

No events

Another improvement is you can reduce your template code further by offloading the onConfirmed handling to be passed to the composable function instead.

tsimport { ref, VNode, defineComponent, h } from 'vue';
import ModalDialog from '@/components/ModalDialog.vue';

export function useModalDialog<TData = unknown>(
  onConfirmProp: (data: TData) => void,
) {
  // ...
  const DialogComponent = defineComponent({
    inheritAttrs: false,
    setup(_, { slots, emit }) {
      function onConfirmed() {
        if (data.value !== undefined) {
          onConfirmProp(data);
        }
      }

      return () =>
        h(
          ModalDialog,
          {
            onClose,
            onConfirmed,
            visible: data.value !== undefined,
          },
          {
            default: () => slots.default?.({ data: data.value }),
          },
        );
    },
  });
  // ....
}

And it would allow us to drop @confirmed bit from our consuming component.

vue<script setup lang="ts">
import { ref } from 'vue';
import { useModalDialog } from '@/features/modal';

interface Item {
  id: number;
  name: string;
}

const items: Item[] = ref([
  //...
]);

const {
  show: onDeleteClick,
  DialogComponent: DeleteItemDialog,
  hide,
} = useModalDialog<Item>((item) => {
  const idx = items.value.findIndex((i) => i.id === item.id);
  items.value.splice(idx, 1);
  console.log('Deleted', item);
  hide();
});
</script>

<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"
      @click="onDeleteClick(item)"
    >
      {{ item.name }}
    </li>
  </ul>

  <DeleteItemDialog v-slot="{ data: itemToDelete }">
    Are you sure you want to delete {{ itemToDelete.name }}?
  </DeleteItemDialog>
</template>

But that really doesn’t affect our experience much, so I will leave it up to you to decide how much wiring this component needs now.

Conclusion

This pattern is really useful to use with components with similar nature. ToolTips, Context menus, Panels, and so on. Such components are often noisy and need a lot of wiring to get them working correctly.

But using the composition API to hide some complexities and those wires. We actually use this pattern in production in Rasayel and it worked really well for us.

In my opinion this pattern improves the DX of such components and cleans up a lot of your consuming/parent components. Try it out and let me know how well it works for you.

Join The Newsletter

Subscribe to get notified of my latest content