Junior vs Senior: Building Modals in Vue

Modal dialogs might seem like a simple UI problem. But they reveal everything about how you approach Vue development.

Watch a junior developer build modals and you'll see scattered booleans, tightly coupled logic, and components that work in isolation but break when requirements change.

But if you watch a senior developer, you'll see elegant patterns that scale effortlessly.

This isn't really about modals.

It's about learning to think in systems instead of features. It's about understanding three foundational patterns that will transform how you write Vue: data store pattern, humble components, and controller components.

These patterns apply everywhere, not just modals. Once you see them, you'll use them for notifications, shopping carts, form wizards, and any complex UI state. You'll write Vue that feels effortless to extend and maintain.

Let's explore both approaches and discover the patterns that separate good Vue developers from great ones.

The Junior Approach: Boolean-Driven Modals

Here's how many developers start building modals:

pages/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<script setup lang="ts">
import { ref } from 'vue'
import BasicModal from '@/components/BasicModal.vue'
const modalVisible = ref(false)
const modalTitle = ref('')
function openModal(title: string) {
modalTitle.value = title
modalVisible.value = true
}
function closeModal() {
modalVisible.value = false
}
</script>
<template>
<div>
<button @click="openModal('Hello Modal')">
Open Modal
</button>
</div>
<BasicModal
v-if="modalVisible"
:title="modalTitle"
@close="closeModal"
/>
</template>

This approach keeps all modal state directly in the page component. You create two reactive variables: modalVisible tracks whether the modal should display, and modalTitle stores the content to show.

The openModal function handles both setting the title and making the modal visible. You call it from your button click, passing in the desired title. The modal only renders when modalVisible is true, thanks to the v-if directive.

When users want to close the modal, they trigger the @close event. Your closeModal function responds by setting modalVisible back to false, which removes the modal from the DOM.

The modal component itself is straightforward:

components/BasicModal.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script setup lang="ts">
const props = defineProps<{ title: string }>()
const emit = defineEmits(['close'])
</script>
<template>
<div>
<div @click="emit('close')" />
<div>
<h2>{{ props.title }}</h2>
<p>
This modal is managed by booleans
on the page component.
</p>
<button @click="emit('close')">Close</button>
</div>
</div>
</template>

It receives a title prop and displays it in an h2 element. The backdrop div responds to clicks by emitting a close event, and the close button does the same.

This component has no internal state. It just renders what it receives and emits events when users interact with it. The parent component handles all the show/hide logic by controlling the v-if directive.

The structure is straightforward: backdrop for clicking outside, content area with title and close button. But notice how tightly coupled this is to the parent's boolean state management.

This approach works for one modal on one page. But it doesn't scale.

The Senior Approach: Composables + Controller Pattern

The senior version separates logic from presentation using three components:

First, a global state store:

composables/useModalStore.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { reactive, toRefs, readonly } from 'vue'
export interface Modal {
id: number
component: any
props?: Record<string, unknown>
}
const state = reactive({
modals: [] as Modal[],
})
let id = 0
export default function useModalStore() {
const { modals } = toRefs(state)
function openModal(
component: any,
props: Modal['props'] = {},
) {
state.modals.push({ id: ++id, component, props })
}
function closeTopModal() {
if (state.modals.length > 0) {
state.modals.pop()
}
}
return {
modals: readonly(modals),
openModal,
closeTopModal,
}
}

This composable creates a global modal system using the Data Store Pattern. The state lives outside any component, making it accessible from anywhere in your app.

The modals array acts as a stack. Each modal gets a unique ID and can display any Vue component with optional props. You push new modals to the end of the array, and pop them off when closing.

The openModal function accepts any Vue component and its props. This flexibility lets you turn any component into a modal without changing its code. The component doesn't need to know it's being displayed in a modal.

The closeTopModal function removes the most recent modal using array pop(). This creates natural LIFO (last in, first out) behavior for modal stacking.

Notice how the composable returns a readonly version of modals. This prevents components from directly mutating the array while still allowing reactive updates.

Second, a humble base component:

components/BaseModal.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts">
defineProps<{ zIndex: number }>()
const emit = defineEmits(['close'])
</script>
<template>
<div :style="{ zIndex }">
<div @click="emit('close')" />
<div>
<slot />
</div>
</div>
</template>

This is a Humble Component that focuses purely on presentation.

It renders a modal structure with backdrop and content area, but has no knowledge of what content it displays.

The zIndex prop lets you control stacking order when multiple modals are open, and the slot lets you pass any component or HTML through the slot. This makes the base modal reusable for any content type.

The component emits a close event when users click the backdrop, but it doesn't handle the closing logic itself. It just communicates user intent upward, following the humble component pattern.

Third, a controller component that orchestrates everything:

components/ModalController.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<script setup lang="ts">
import useModalStore from '@/composables/useModalStore'
import BaseModal from '@/components/BaseModal.vue'
const { modals, closeTopModal } = useModalStore()
const topModal = computed(() =>
modals.value.length > 0
? modals.value[modals.value.length - 1]
: null,
)
</script>
<template>
<Teleport to="body">
<Transition name="modal-fade">
<BaseModal
v-if="topModal"
:key="topModal.id"
:z-index="1000"
@close="closeTopModal"
>
<component
:is="topModal.component"
v-bind="topModal.props"
/>
</BaseModal>
</Transition>
</Teleport>
</template>

The Controller Component orchestrates between the store and the presentation layer. It connects the modal data to the UI without either side knowing about the other.

The topModal computed property gets the most recent modal from the stack. This reactive property updates automatically when modals are added or removed from the store.

The <Teleport> renders the modal at the document body level, breaking it out of the normal component tree. This prevents z-index issues and ensures modals always appear on top.

The <component :is> directive dynamically renders whatever component is stored in topModal.component. The v-bind="topModal.props" passes along any props that component needs.

Now you can open modals from anywhere in your app:

// From any component or composable
const { openModal } = useModalStore()
// Open any component as a modal
openModal(UserProfileModal, { userId: 123 })
openModal(ConfirmDeleteModal, { itemName: 'Project Alpha' })

Why the Senior Approach Wins

The senior version solves every limitation of the junior approach:

Reusability Across Your App

The junior version ties modal logic to individual pages. You need separate booleans and handlers everywhere you want modals.

Look at what happens when you need modals in multiple places:

pages/users.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<script setup lang="ts">
const userModalVisible = ref(false)
const confirmDeleteVisible = ref(false)
const selectedUser = ref(null)
function openUserModal(user) {
selectedUser.value = user
userModalVisible.value = true
}
function openDeleteConfirm(user) {
selectedUser.value = user
confirmDeleteVisible.value = true
}
// ... more duplicate logic
</script>
<template>
<!-- Duplicate modal management code -->
<UserModal
v-if="userModalVisible"
:user="selectedUser"
@close="userModalVisible = false"
/>
<ConfirmModal
v-if="confirmDeleteVisible"
@close="confirmDeleteVisible = false"
/>
</template>

Every page needs its own boolean state, handlers, and modal mounting logic. You're duplicating the same pattern everywhere.

But the senior version gives you global access. Call openModal() from any component, composable, or even outside Vue entirely:

// From any component
function handleUserClick(user) {
openModal(UserDetailModal, { user })
}
// From a composable
function useUserActions() {
function deleteUser(user) {
openModal(ConfirmDeleteModal, {
message: `Delete ${user.name}?`,
onConfirm: () => api.deleteUser(user.id)
})
}
return { deleteUser }
}
// Even from outside Vue
window.addEventListener('unhandledrejection', () => {
openModal(
ErrorModal,
{ message: 'Something went wrong' },
)
})

No more duplicating show/hide logic across your codebase. One system handles all the modals!

Natural Modal Stacking

What happens when you need to open a modal from within another modal? (Whether or not this is good UX is between you and your design team...)

Let's try implementing this with booleans:

<!-- This gets messy fast -->
<script setup lang="ts">
const firstModalVisible = ref(false)
const secondModalVisible = ref(false)
const thirdModalVisible = ref(false)
// Which modal should close when user hits escape?
// How do you handle backdrop clicks?
// What if the second modal needs to close the first?
function closeModal() {
// Which one do we close??
if (thirdModalVisible.value) {
thirdModalVisible.value = false
} else if (secondModalVisible.value) {
secondModalVisible.value = false
} else {
firstModalVisible.value = false
}
}
</script>
<template>
<!-- Multiple modals fighting for z-index -->
<FirstModal v-if="firstModalVisible" />
<SecondModal v-if="secondModalVisible" />
<ThirdModal v-if="thirdModalVisible" />
</template>

You end up with complex conditional logic just to track which modal should be active. Z-index conflicts, keyboard navigation problems, and confusing close behavior.

The senior version handles this naturally by treating modals as an array:

// Open a chain of modals naturally
function showUserDetails(user) {
openModal(UserDetailModal, {
user,
onEditClick: () => {
openModal(UserEditModal, {
user,
onDeleteClick: () => {
openModal(ConfirmDeleteModal, {
message: 'Are you sure?'
})
}
})
}
})
}

Each new modal gets pushed onto the stack, and closing removes the top item. You get proper LIFO behavior without any extra work. The controller automatically handles z-index and backdrop behavior.

With this approach the implementation becomes straightforward, now you just need to decide whether the UX is worth it (and if you do please don't stack too many!).

Separation of Concerns

The junior version mixes presentation, state management, and business logic together. The modal component knows about styling, the page handles state, and everything is tightly coupled.

Look at how responsibilities blur in the junior approach:

pages/dashboard.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<script setup lang="ts">
// State management
const modalVisible = ref(false)
const modalData = ref(null)
// Business logic
async function handleUserAction(userId) {
const user = await fetchUser(userId)
modalData.value = user
modalVisible.value = true
}
// UI logic
function closeModal() {
modalVisible.value = false
modalData.value = null
}
</script>
<template>
<!-- Mixed concerns in one place -->
<UserModal
v-if="modalVisible"
:user="modalData"
@close="closeModal"
@save="handleSave"
@delete="handleDelete"
/>
</template>

Your page component becomes a dumping ground for modal state, business logic, and UI coordination. Changes to modal behavior require editing multiple files.

The senior version separates these cleanly:

composables/useModalStore.ts
1
2
3
function openModal(component, props) {
state.modals.push({ id: ++id, component, props })
}
components/BaseModal.vue
1
2
3
4
5
6
7
<template>
<div class="modal-backdrop">
<div class="modal-content">
<slot />
</div>
</div>
</template>
components/ModalController.vue
1
2
3
4
5
6
7
8
<template>
<BaseModal v-if="topModal" @close="closeTopModal">
<component
:is="topModal.component"
v-bind="topModal.props"
/>
</BaseModal>
</template>

Each piece has a single responsibility. useModalStore handles pure state logic, BaseModal focuses only on presentation, and ModalController orchestrates the two.

This separation makes each piece easier to understand, test, and modify. You can change modal styling without touching state logic, or add new modal types without modifying the base component.

Better Testing Story

Testing the junior version means testing UI and logic together. You need to mount components, trigger events, and verify DOM changes.

Here's what testing looks like with the junior approach:

test/pages/dashboard.test.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { mount } from '@vue/test-utils'
import Dashboard from '@/pages/dashboard.vue'
test('opens and closes user modal', async () => {
const wrapper = mount(Dashboard)
// Need to mock API calls
vi.mocked(fetchUser).mockResolvedValue({ name: 'John' })
// Trigger business logic AND UI logic together
await wrapper.find('[data-test="user-button"]')
.trigger('click')
// Test DOM changes
expect(wrapper.find('.modal').exists()).toBe(true)
expect(wrapper.find('.modal h2').text()).toBe('John')
// Test closing
await wrapper.find('.modal-close').trigger('click')
expect(wrapper.find('.modal').exists()).toBe(false)
})

You're testing everything at once: API calls, state changes, DOM updates, and user interactions. Tests are slow, brittle, and hard to debug when they fail.

The senior version lets you test each piece in isolation:

test/composables/useModalStore.test.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useModalStore } from '@/composables/useModalStore'
test('modal store manages stack correctly', () => {
const { openModal, closeTopModal, modals } = useModalStore()
openModal('ComponentA')
openModal('ComponentB')
expect(modals.value).toHaveLength(2)
expect(modals.value[1].component).toBe('ComponentB')
closeTopModal()
expect(modals.value).toHaveLength(1)
expect(modals.value[0].component).toBe('ComponentA')
})
test/components/BaseModal.test.js
1
2
3
4
5
6
7
8
9
10
11
12
import { mount } from '@vue/test-utils'
import BaseModal from '@/components/BaseModal.vue'
test('emits close on backdrop click', async () => {
const wrapper = mount(
BaseModal,
{ props: { zIndex: 1000 } }
)
await wrapper.find('.modal-backdrop').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})

Each test is fast, focused, and easy to understand. When a test fails, you know exactly which piece broke. You can test business logic without mounting components, and test UI behavior without mocking APIs.

Spending the time to write tests is hard enough, so anything you can do to make them easier is a good thing.

The Patterns That Make It Work

The senior approach succeeds because it combines three powerful patterns:

Data Store Pattern creates a global singleton for modal state. This gives you consistent access from anywhere while keeping state predictable and testable.

Humble Components handle pure presentation without business logic. BaseModal just renders what it's told and emits events upward.

Controller Components orchestrate between logic and presentation. ModalController connects the store to the UI without either side knowing about the other.

These patterns work together to create a system that's both simple to use and powerful enough to handle complex requirements.

The Mindset Shift That Changes Everything

The difference between junior and senior Vue developers isn't about knowing more APIs or memorizing lifecycle hooks.

It's about seeing patterns where others see features.

When a junior developer builds a modal, they solve the immediate problem. Boolean for visibility, event handler for closing, some CSS for styling. It works, ships, and everyone moves on.

But when a senior developer builds the same modal, they're already thinking three steps ahead. They see the notification system that will need stacking. The confirmation dialogs that will need dynamic content. The form wizards that will need complex state management.

They don't build a modal. They build a system that happens to include modals.

This is the fundamental shift that transforms how you write Vue.

You stop thinking in isolated components and start thinking in composable patterns. You stop solving today's problem and start building tomorrow's foundation.

The three patterns we explored (data store, humble components, and controller components) aren't just modal techniques. They're the building blocks of scalable Vue architecture.

Once you see these patterns, you can't unsee them. They'll transform how you approach every complex component you build.

Ready to Level Up Your Vue Skills?

If you want to master these patterns and dozens more like them, I've created something special for you.

Clean Components Toolkit is my comprehensive course that teaches you to think like a senior Vue developer. You'll learn the exact patterns that separate good developers from great ones, including the three we covered today and many more.

Inside, you'll discover 21 patterns that will transform how you write Vue. Each pattern includes:

  • An overview of the pattern
  • A step-by-step refactoring of real-world code
  • A quiz to test your understanding

Stop building features. Start building systems.

Get Clean Components Toolkit →