Vue.js — Manage your Modal window(s) effortlessly using EventBus

Dana Janoskova
Vue.js Developers
Published in
7 min readNov 9, 2019

As a both Vue.js and React developer, I’ve been assigned to/leading various projects which were mostly about building UIs. One thing I was always battling was popup Dialogs — not in the sense that they didn’t work, but I was always left wondering whether there’s a better way to code them.

I’ve also seen other approaches, mostly storing modal window states in global app store, which of course required developers to store more and more data as the app grew — and so did the number of used modal windows. The store quickly became bloated with data that was relevant only for a short amount of time for a small part of the UI.

For those interested only in the final open source code, it’s at the end of the article as usual!

While building SPAs with Vue.js, my approach was mostly to create a <Modal /> wrapper component (with a slot for the content) which held an internal boolean open state and had methods such as open() and close(), which toggled its state. I accessed those methods through a $ref in the Component that displayed the modal. An example could look similar to this:

methods: {
openModal () {
this.$refs.modal.show()
}
}

I liked this approach in a sense that each Component had its own modal and was responsible for it. Thanks to this the application wasn’t prone to monolithic structure. I don’t much like keeping the modal state inside the Component that’s displaying the modal (and passing it as a prop) because the code often becomes way too repetitive and robust if you have to change the modal state in the displaying components. That’s why I chose the approach where the modal took care of its own open state and exposed the toggle methods through $ref.

The downside to this was that there were simply too many <Modal /> components as my app grew. They were also inside the Component that was displaying them HTML wise, so there were sometimes overlay issues which z-index simply couldn’t solve. I was wondering whether there’s a better way.

One modal root to rule them all

I already mentioned the z-index issue. When you need to display something on the top of everything else HTML wise, it only makes sense that your tree should be structured accordingly, instead of using hacks to force your elements to appear on the top of your site when actually.. they’re not.

So we’re aiming for something like this

<!doctype html>
<html>
<body>
<div id="app">
<div class="content">Lorem Ipsum..</div>
<div id="modal-root"></div>
</div>
</body>
</html>

Where #modal-root will always be on the bottom (but top, visually 😅) of your structure and it will always display your current selected element.

That means if you’re aiming to have multiple modals in your app, this is not an ideal approach for you. I will go with cases where I need only one dialog open at a time.

We will accomplish this reactivity of the root element by using EventBus, so any child can trigger opening the dialog displaying any component from anywhere.

1. Creating the EventBus

src/eventBus.js

Let’s start by creating a simple ModalBus inside our eventBus.js file. I chose a named export because I aim to work with more event buses, but that’s really a personal preference. If you feel comfortable using default exports, feel free to do so.

2. Creating the Modal (presentational) Component

src/components/common/Modal.vue

As you can see, this is merely a presentational component. It gets the isOpen and title properties from a parent we don’t have yet.

We handle several things in here:

  • Emitting an onClose call when we click the backdrop outside the dialog
  • Displaying the whole Modal component conditional logic
  • Displaying title conditional logic
  • Using <slot /> to display children inside the Modal
  • Defining some basic styles

3. Creating the ModalRoot component

src/components/ModalRoot.vue

The ModalRoot component is the one that’s listening to the ModalBus events and handling all the logic — and the one that’s displaying the <Modal />. It’s also the one we want to place inside our <App /> on the bottom of our HTML structure.

Let’s debunk what’s happening under the hood:

  • Script:

We’re storing 3 things in our state (data):

data () {
return {
component: null,
title: '',
props: null
}
},

Those properties will be received via the ModalBus and they’re carrying the information about the component we’re going to display, the dialog’s title and any needed props for the child component inside the <Modal />.

created () {
ModalBus.$on('open', ({ component, title = '', props = null }) => {
this.component = component
this.title = title
this.props = props
})
document.addEventListener('keyup', this.handleKeyup)
},

Right upon the <ModalRoot />'s creation, we’re connecting to the ModalBus and listening to the open event, which is what we’ll call when we want to open the Modal from any component, passing our values in. You can see we’re accepting and setting those parameters that I mentioned above — component, title and props. We’re also adding a listener for the (Escape) key.

beforeDestroy () {
document.removeEventListener('keyup', this.handleKeyup)
},

We have to be careful and destroy the listener before the <ModalRoot /> itself is destroyed.

methods: {
handleClose () {
this.component = null
},
handleKeyup (e) {
if (e.keyCode === 27) this.handleClose()
}
},

Then there’s our simple handleClose() method which is only setting component to null (which changes our isOpen prop for the <Modal /> component, more in the template section) and handleKeyup() method, which is checking whether we’re pressing the Escape key and then calling handleClose(), should the condition be met.

(Note that you can also listen to the close event the same way you listen to open and call handleClose() inside, so the modal can also be closed from anywhere)

  • Template:
  • We’re using our <Modal /> presentational component to wrap our dynamic <component />
  • We’re setting the isOpen and title props to <Modal /> based on our incoming data (isOpen is true whenever component’s not empty)
  • We’re also passing an @onClose listener to react to <Modal />'s backdrop click, which is when an $emit('onClose') happens in the <Modal />
<component :is="component" @onClose="handleClose" v-bind="props" />
  • We’re displaying the <component /> inside the <Modal />, therefore making use of the <slot /> functionality inside it
  • We’re also listening to the @onClose emit (the same way like in <Modal /> )
  • We’re binding whatever props we set in the open() ModalBus call to the <component />, so they find their way to the destined component.

4. Making use of the <ModalRoot />

Everything should be connected now — we just need to make use of it! I’ve created a few examples which are using Tailwind to show how the <ModalRoot /> can actually be used.

src/App.vue
  1. Success alert

The first case is very simple. We’re calling a generic <Alert /> component and passing some basic config in:

openSuccessAlert () {
ModalBus.$emit('open', {
component: Alert,
props: { text: 'Everything is working great!', type: 'success' }
})
},

The <Alert /> component is expecting a text and a type. We’re not passing any title for our dialog, so it won’t be displayed.

src/components/common/Alert.vue

2. Danger alert

openDangerAlert () {
const props = {
type: 'error',
text: 'The server returned 500 again! omg!'
}
ModalBus.$emit('open', { component: Alert, title: 'An error has occured', props: props })
},

We’re using the <Alert /> component again, this time passing the "error" type and also providing a title for the dialog.

3. A Component that’s closable from inside

openClosableInside () {
ModalBus.$emit('open', { component: ClosableInside, title: 'Close dialog from component' })
},

We’re not passing anything special to the open() call, instead the magic happens inside the <ClosableInside /> example component:

src/components/examples/ClosableInside.vue
<Button @click="$emit('onClose')" color="gray">Close</Button>

Because the <ModalRoot /> is displaying the component, in our case the <ClosableInside /> component, and it’s listening to the @onClose event, we can $emit it inside the component and the modal window will close. The “React way” would be passing this close handler via props, which is also possible, of course.

4. A Sign In form

This form is almost completely copied from the Tailwind documentation and it’s included for presentational purposes. It doesn’t do much, but you can pass in whatever props you like and work with them.

openSignIn () {
ModalBus.$emit('open', { component: SignInForm, title: 'New user' })
}
src/components/examples/SignInForm.vue

The beautiful thing about this is you can extend it whatever way you want. Let’s say we want to add a modal animation!

src/components/common/Modal.vue

We pretty much just added a <transition name=”fade”> and some CSS rules in the <style /> section:

.fade-enter-active, .fade-leave-active {
transition: 0.5s;
}

.fade-enter, .fade-leave-to {
opacity: 0;
}

.fade-enter .modal-dialog, .fade-leave-to .modal-dialog {
transform: translateY(-20%);
}

A pretty common case is where you want to persist the modal’s (open) state on backdrop click, for example when you’re filling a form and you want to protect the user from accidentally closing the window.

src/components/ModalRoot.vue
  • The <ModalRoot /> is storing another property in the data() now — closeOnClick: true . We altered the ModalBus.$on(‘open’, ...) function to accept the new param.
ModalBus.$on('open', ({ component, title = '', props = null, closeOnClick = true }) => {
this.component = component
this.title = title
this.props = props
this.closeOnClick = closeOnClick
})

Let’s create a different handler for the <Modal />'s @onClose event, which gets emitted when a user clicks the backdrop of the <Modal />.

@onClose="handleOutsideClick"

The body of the function now determines whether to call handleClose() or not based on the closeOnClick property

handleOutsideClick () {
if (!this.closeOnClick) return
this
.handleClose()
},

And that’s it! Let’s try it with the <SignInForm /> component to see if it works by passing the closeOnClick parameter (now you can close it using only the Escape key):

openSignIn () {
ModalBus.$emit('open', { component: SignInForm, title: 'New user', closeOnClick: false })
}

You can find the complete working repository at https://github.com/DJanoskova/Vue.js-Modal-context

The demo app with enhancements is live at https://vue-modal-context.netlify.com/

--

--

Dana Janoskova
Vue.js Developers

My current stack consists of JS, TS, Node.js and Dart, coding Vue.js, React or Flutter applications. I focus on delivering good UX and writing clean code.