Reducing component noise with Composition API
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.