DEV Community

Cover image for Vue 3 Expressive API for Confirmation Modals
Daniele
Daniele

Posted on

Vue 3 Expressive API for Confirmation Modals

Whenever a user has to perform a potentially destructive action, we, as front-end developers, have to make sure that this is what the user has intended.

Otherwise all kind of dangers ensue.

I'll be honest with you, confirmation modals suck.

More so if you have to implement 3 in a row. And not all of them have to be shown. It depends on the state of the form. Yuck.

If you've ever written a modal base component I'm sure you've come across a not-so-great way of handling the flow of execution of your code.

All because modals are essentially asynchronous in their nature.

In Vue 2's standard flow of props down and events up, whenever you wanted to show a modal and catch the user's choice we had to do this:

<template>
    <div v-if="visible">
        Do you agree?
        <button
            @click.prevent="$emit('confirmation', true)"
        >I Agree</button>
        <button 
            @click.prevent="$emit('confirmation', false)"
        >I Decline</button>
    </div>
</template>

<script lang="ts">
import Vue from "vue";

export default Vue.extend({
    name: "BaseModal",
    props: ['visible'],
});
</script>
Enter fullscreen mode Exit fullscreen mode

And, in the consumer component, you would use this modal like this:

<template>
    <button 
        @click="modalVisible = true"
    >Show modal</button>
    <base-modal
        :visible="modalVisible"
        @confirmation="handleConfirmation" 
    />
</template>

<script lang="ts">
import Vue from "vue";

export default Vue.extend({
    name: "RandomComponent",
    data(): {
        return {
            modalVisible: visible,
        },
    },
    methods: {
        handleConfirmation(confirmed: boolean): void {
            this.modalVisible = false;

            // do something with the value
        }
    }
});
</script>
Enter fullscreen mode Exit fullscreen mode

Now, what happens if you have to show this modal before the actual submission of a form?

<template>
    <form @submit.prevent="handleSubmit">
        <!-- other stuff... -->
        <button type="submit">Submit</button>
        <base-modal
            :visible="modalVisible"
            @confirmation="handleConfirmation" 
        />
    </form>
</template>

<script lang="ts">
import Vue from "vue";
import axios from 'axios';

export default Vue.extend({
    name: "RandomFormComponent",
    data(): {
        return {
            form: {
                // form data here
            },
            modalVisible: visible,
        },
    },
    methods: {
        handleSubmit(): void {
            // validate form
            // show confirmation modal
            this.modalVisible = true;
        },
        handleConfirmation(confirmed: boolean): void {
            this.modalVisible = false;

            // do something with the value
             if (confirmed) {
                 axios.post(ENDPOINT, { ...this.form });
             } else {
                 // do something else
            }
        }
    }
});
</script>
Enter fullscreen mode Exit fullscreen mode

Now, the actual code that submits the form lives inside the event handler of the confirmation modal.

That's not good.

It's not the modal's event handler responsibility to submit the form data to the API.

Now, imagine that you have to show the confirmation modal only if the form's state requires it, e.g.: a dangerous checkbox has been checked.

You get the idea.

Promises to the rescue.

Given that modals are an asynchronous operation let's entertain the idea of a function that, when called in the submit event handler, returns a promise with the user's choice.

This allows us retain the submission logic inside the form's handler itself, even allowing us to await the user's choice and continue the execution.

This is how you would use this custom hook in Vue 3, making full use of the Composition API.

Let's start by redefining our BaseModal component to be extended.

<template>
    <div v-if="visible">
        <slot>
            Are you sure that you want to do something potentially dangerous?
        </slot>
        <slot #actions>
            <button
                @click.prevent="$emit('confirmation', true)"
            >Proceed</button>
        </slot>
    </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";

export default defineComponent({
    name: "BaseModal",
    props: ['visible'],
});
</script>
Enter fullscreen mode Exit fullscreen mode

Now, for the consumer component.

<template>
    <form @submit.prevent="handleSubmit">
        <!-- other stuff... -->
        <button type="submit">Submit</button>
        <base-modal
            :visible="confirmationModal.visible"
        >
            <template #default>
                Do you agree?
            </template>
            <template #actions>
                <button @click="confirmationModal.tell(true)">I Agree</button>
                <button @click="confirmationModal.tell(false)">I Decline</button>
            </template>
        </base-modal>
    </form>
</template>

<script lang="ts">
import { defineComponent, reactive } from "vue";
import { usePromisedModal } from "../composables/usePromisedModal";

export default defineComponent({
    name: "Vue3FormComponent",
    setup() {
        const confirmationModal = usePromisedModal<boolean>();

        const form = reactive({ /* form data here */ });

        const handleSubmit = async (): void => {
            const confirmed = await confirmationModal.ask();

            // do something… this code runs after the user's has made their choice.
            if (confirmed) {
                axios.post(ENDPOINT, { ..form });
            } else {
                // do something else
            }
        };

        return {
            form,
            handleSubmit,
            confirmationModal,
        };
    }
});
</script>
Enter fullscreen mode Exit fullscreen mode

As you can see we can use a more expressive API to show a confirmation modal.

By the way, usePromisedModal is generically typed to allow you to operate with whatever input you need from the modal.

It could also be a string.

Another example:

<template>
    <div>
        <button @click="show">Show modal</button>
        <div v-if="visible">
            Do you agree?
            <button @click="tell('agree')">I Agree</button>
            <button @click="tell('decline')">I Decline</button>
        </div>
        <span>Result: {{ result }}</span>
    </div>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";
import { usePromisedModal } from "../composables/usePromisedModal";

export default defineComponent({
    name: "test",
    setup() {
        const { visible, ask, tell } = usePromisedModal<string>();
        let result = ref("");

        const show = async () => {
            result.value = await ask();
            // do something else… this code runs after the user's has made their choice.
        };

        return {
            show,
            visible,
            tell,
            result
        };
    }
});
</script>

Enter fullscreen mode Exit fullscreen mode

Now, I'm sure you're asking yourself, can I use this today with Vue 2?

Of course you can!

You can utilize Vue's Vue.observable to replace the usePromisedModal's ref.

I'll leave the implementation details to you.

I hope this pattern will be useful to, at least, someone else besides me.

Happy coding!

Top comments (0)