Business Popups Are Route Navigation, Not Child Components
What to Consider When Building an Imperative Popup Tool — Why I Designed vue-layerx This Way
If you've written enough mid-to-backend systems, the awkwardness around popups should be familiar: three Dialogs on one page, three sets of visible, forms welded to their containers, impossible to reuse without surgery. So an intuitive thought quickly surfaces — can we handle all business popups with a single open() call, just like ElMessageBox?
I've thought about it, and I've tried it. But after building it, I'm more convinced than ever: a one-line open() is just intuition, not an ideal.
Business popups and lightweight notifications (Toast/Message) are entirely different things; if you just crudely swap "declarative" for "imperative mounting," the original awkwardness usually recombines in a more hidden form.
I'm the author of vue-layerx. This article isn't a dry API tutorial — I want to talk through, with fellow developers, exactly what we're fighting against when designing an imperative popup tool, and why vue-layerx ended up looking the way it does.
It wasn't a case of drawing up a "pretty API" first and then reverse-engineering justifications. It's the inevitable result of being cornered by boundary problems, with constraints narrowing the solution space step by step. In my view, the ideal form is precisely this constraint corridor woven from orchestration and runtime concerns.
This article follows that thread: Core pain points → Why invocation must be imperative → Why container and content must be forcibly split → The orchestration tax → The runtime tax → Gradual adoption → Converging on vue-layerx.
1. The Problem: Where Does Our Awkwardness Actually Come From?
In the Vue world, the "orthodox" way to write a popup looks roughly like this:
<template>
<ElDialog v-model="visible" title="Edit User">
<UserForm @success="onSuccess" />
</ElDialog>
</template>
This approach isn't wrong in itself: it aligns with the component tree's lifecycle, is easy to debug, and has intuitive data flow. For a single popup opened in-place, it might even be the optimal solution.
But as pages multiply and deepen, the nightmare begins:
- Boilerplate expands linearly: N popups ≈ N sets of
visiblevariables + N repeated template blocks. - Invocation points become extremely awkward: The trigger might be in a table row or even a deeply nested sub-tree. To open a popup, you either bubble events upward endlessly or stuff the entire Dialog into the child component (causing further sinking and confusion of component responsibilities).
- Container and content are welded solid: One day the product manager asks for the
UserFormto be embedded inline on a page and also opened in a popup. You discover it's already welded toElDialogin the same file — impossible to detach.
On the other hand, imperative calls like Message / MessageBox feel great, but their UI and interactions are basically fixed. They can't support business forms with validation and complex state.
People often summarize this pain as "too much declarative boilerplate, I want imperative." I think that's only half right.
💡 The more fundamental problem is: we've long treated "invoking a popup" as "rendering an ordinary child component." The core model is wrong, and no amount of patching afterward will fix the awkwardness.
2. Why Must Invocation Be Imperative?
Let's start with the core conclusion:
The invocation of business popups should be imperative; the content of the popup must remain ordinary declarative components. "Imperative" refers to the act of "which one to open," not rewriting the entire UI tree using functions.
Why? Because invoking a popup is fundamentally more like route navigation than "mounting one more child node at the current position."
Anyone who's written Vue Router is used to separating two things:
- Page content: ordinary declarative components handling business UI.
- Navigation/invocation: triggered via imperative/semi-imperative means like
router.push(...)or<RouterLink>.
Almost no one demands that every possible page you might navigate to be pre-mounted in the current parent's template, toggled by a pile of v-if for "which page is currently active." Everyone accepts that "where to go" is a navigation scheduling problem, not a "render one more child node" problem.
Popups fit this model perfectly:
| Dimension | Routing (Vue Router) | Business Popup (Model I Advocate) |
|---|---|---|
| Actual Content | Page component (Page) | Business content component (form, detail, filter panel) |
| Host Container | Layout component / <RouterView> |
Dialog / Drawer / Popup |
| Invocation Method | router.push() |
layer.open() |
| Current State | Current route match (Route Context) | Whether it's open, which content instance is currently open |
| Sideband Config | route.meta |
Layer default declarations beside the content component (e.g., defineLayer) |
Forcing invocation into the parent component's v-model is equivalent to stuffing the entire router logic into every single page.
Think of it the other way: the content layer stays an ordinary component; the invocation layer is naturally imperative, like navigation. That makes the model coherent.
Extension: The Explosive Pressure from AI-Driven Dynamic Pages
This model pressure is especially urgent in today's AI interaction pages. In a conversation flow, the AI assistant might need user confirmation or form input at any moment — which popup to open and when is entirely decided at runtime based on the AI's intent, not hardcoded as three Dialogs at page initialization. The pre-mount + visible pattern already struggles with fixed mid-to-backend systems; it collapses entirely when facing such dynamic invocation scenarios. Imperative scheduling becomes an absolute necessity here.
This is my first principle when building vue-layerx: invocation belongs to navigation (imperative), content belongs to components (declarative).
3. open() Alone Isn't Enough: You Must Forcibly Split "Container" and "Content"
If the story stopped at the previous section, we'd easily end up with a design like open(UserDialog) — merely replacing the welded body with a function call. Boilerplate is reduced, but the content reuse problem remains untouched.
So the next cut must go to the essence: what exactly is that thing being opened?
Business popups have always mixed two things with entirely different responsibilities:
- Content: concerned with the business itself — fields, validation, submission APIs (e.g.,
UserForm). - Container: concerned with how to pop up — visibility, mask, title bar, expand animation (e.g.,
ElDialog/ElDrawer).
Welding them into one UserDialog.vue saves the most effort in the short term. But the slightest requirement change exposes the flaw:
- The same form needs to be embedded inline on a page and also opened in a popup → you want to reuse the content, but it's dragging a Dialog frame along for the ride.
- The same content uses Dialog on desktop, but Drawer on mobile or narrow screens → you're changing the container, yet forced to touch the business content file.
Similarly, in AI pages, the same business UI might appear directly embedded in the conversation bubble stream or as a popup. The host changes; the content shouldn't. If content and Dialog are welded together, you're forced to maintain two highly similar copies of code.
🛠️ Splitting them isn't about inventing two more concepts — it's about making "swapping containers" and "reusing content" two independent, non-interfering things.
There's a hard standard here: the content component must remain pure — it should only know props in / emits out. Whether embedded inline, in a conversation stream, or in a popup, it's written exactly the same way; only the host changes.
A bonus emerges: projects usually already have mature Element / Antd or a team-built BaseDialog. The container doesn't need to be rebuilt, and the tool shouldn't force the project to switch stacks. What's missing isn't another UI component library, but an elegant middle orchestration layer.
Thus, vue-layerx's basic form settled into this:
import { createLayer } from 'vue-layerx'
import { ElDialog } from 'element-plus'
import UserForm from './UserForm.vue'
// 1. Pin down the project-level container host
export const useDialog = createLayer(ElDialog)
// 2. Bind business content
const dialog = useDialog(UserForm)
// 3. Execute invocation
dialog.open()
Three steps, precisely mapping to decisions across three dimensions. Someone might ask: why not combine it into one step as useDialog(ElDialog, UserForm)?
Because "which container suite to use, and the container's global default behavior" is typically a project-level or global-level convention; while "which content to open" is a specific feature-level binding. Merging them into one call leaves no foothold for global defaults and makes it hard to crystallize a team-wide useDialog or useDrawer shortcut utility.
4. After the Split: What "Taxes" Must We Pay on Orchestration?
If a tool's design only goes as far as create → use → open, many open-source libraries can tell a coherent story. What truly determines the final complex shape are the orchestration and contract constraints that must be simultaneously satisfied after the split.
1. Three Roles, Three Places for Configuration Merging
After splitting container and content, three roles appear on the field, and each of them "has the right" to make configuration demands:
- Container/Project-level: requires uniform width, default click-mask-does-not-close (written in
createLayer). - Content-level: when this business piece is opened, it should inherently be titled "New User," and auto-close after successful submission (written beside the content component).
- Caller: this particular invocation is special — temporarily change the title, make it a bit wider (written in the
open()parameters).
Once container, content, and caller are split, configuration inevitably lands in three places. Without a rigorous merge orchestration mechanism, the split is practically unusable in real business scenarios.
vue-layerx's configuration merge priority design is very intuitive — the closer to "this execution," the higher the weight:
$$\text{open() per-call config} > \text{useDialog instance config} > \text{defineLayer content defaults} > \text{createLayer global/container defaults}$$
2. Content Must Be Able to Reverse-Configure the Container (Proximity Principle for Title and Config)
A UserForm form, no matter which page it's popped up from, usually has a fixed title. If these configurations can only be written in the caller's open({ title: '...' }), the configuration contract scatters everywhere, and switching entry points for invocation risks omissions.
Therefore, content must have the ability to declare its own container default behavior (title, action area slots, etc.). vue-layerx provides the defineLayer macro, which only takes effect when this piece of content is opened as a popup:
<!-- UserForm.vue -->
<script setup>
import { defineLayer } from 'vue-layerx'
defineLayer({
props: { title: 'Edit User', width: '480px' },
})
</script>
3. Cross-Spacetime Slot Delivery: JSX Can't Be the Only Main Path
When a content component needs to deliver buttons to the container's #footer slot, many libraries force developers to write them using JSX or h() functions. But considering team collaboration, many people are only accustomed to writing SFC templates in daily work. Forcing JSX creates a huge collaboration rift.
To achieve "multi-person collaboration + template syntax," vue-layerx introduces LayerTemplate. Using the obtained layer context, it magically delivers a piece of UI from a standard SFC template into the external container's same-named slot:
<!-- UserForm.vue -->
<script setup>
import { defineLayer, LayerTemplate } from 'vue-layerx'
const layer = defineLayer()
</script>
<template>
<div class="form-body">...form body...</div>
<!-- Automatically delivered to the external ElDialog's footer slot -->
<LayerTemplate :to="layer" name="footer">
<el-button>Cancel</el-button>
<el-button type="primary">Confirm</el-button>
</LayerTemplate>
</template>
4. Strictly Guard the Boundary: Don't Make the Popup a First-Class Citizen of the Content
The earlier sections nailed down that "content must be an ordinary component." So after successful submission, how should the content component close the popup?
The most intuitive thought is to inject a close() method into the content component. But I firmly rejected this approach.
Once the content can directly call close() internally, the popup becomes a first-class citizen of the content. This means the component's lifecycle is strongly coupled to the popup framework — it's no longer an ordinary component that can be directly reused by any host (inline, conversation stream, etc.).
💡 The reasonable logic should be: the content just does its job and "reports business results" outward, while the close action is the host's (external container's) reaction upon receiving that report.
In vue-layerx, the content component only needs to purely trigger emit('success'). Whether to close the window is wired by the outer closeOn contract. This can also be written in the defineLayer side configuration, but the component itself absolutely never touches the close instance:
<!-- UserForm.vue -->
<script setup>
import { defineLayer } from 'vue-layerx'
defineLayer({
content: { closeOn: ['success'] }, // Declare: auto-destroy layer when 'success' event is received
})
const emit = defineEmits(['success'])
</script>
Content component emit('success') ➔ closeOn contract intercepts ➔ Auto-triggers instance close()
Under this design, open/close always belongs to the external instance; the content component maintains absolute purity and freedom.
5. The Tree Outside the Main App: Runtime Taxes Still Owed
When using imperative invocation, the popup's DOM tree often leaves the current page's render tree (typically mounted directly under body). This brings three unavoidable runtime problems:
- Lifecycle: When to mount? How to elegantly divide labor between close and unmount?
- Context Drift: If the content component needs to
injectglobal theme, i18n, or routing environment, what happens when the ancestor chain breaks after the tree drifts out? - DevTools Invisibility: When debugging, can these dynamically mounted components be seen and interacted with in Vue DevTools?
If these are ignored, no matter how pretty the API design, it will spectacularly collapse under real-world theme switching, i18n plugins, or daily troubleshooting.
vue-layerx pays two solid runtime taxes for this:
- Introducing the LayerHost concept: A popup instance must know "whose world it's mounted in." When created inside a component's setup, it automatically binds to the current Host, perfectly bridging the main app's
appContextandprovides. Context isn't an external option plugged in — it's a thread grown together with the mount lifecycle. - Using an independent
createAppto accommodate DevTools: Early on, many libraries used therender()function to forcibly borrow context, causing components to go "invisible" in DevTools. vue-layerx later switched to lightweight mounting via an independentcreateApp(LayerApp), with internal Host property bridging. This preserves the context chain while making the entire layer tree clearly visible and normally debuggable in DevTools.
6. On "Gradual Adoption" in Real Projects
Everything we've derived so far is the ideal target state: container-content decoupling, full imperative orchestration. But reality is often brutal — projects already have piles of old UserDialog.vue files (with <el-dialog> and forms glued tightly together), and there's absolutely no schedule window to refactor them in the short term.
For gradual adoption, my thinking principles are:
- Don't compromise the target state: Don't create a permanent model that deviates from the ideal just to accommodate legacy code.
- Don't open a second pipeline for legacy code: If you end up with two sets of types, two sets of merge rules, greenfield projects and legacy debt will fork forever, making migration costs even higher.
- Treat the monolith as "content" for now: When an old component's container and business are glued together, in our model, the whole thing still only belongs to
content. We run it through the pipeline with a "no outer container (no-container)" flag.
This way, when there's time later to strip the internal ElDialog from the old component, its role direction is still correct. If you mistakenly onboard the old component as a "container" from the start, the future refactoring migration direction would be completely reversed.
// Gradual adoption: directly open the unsplit monolith component as content
const legacyDialog = useDialog(OldUserDialog, {
container: null // Tell the tool: no need to wrap another dialog shell outside
})
legacyDialog.open()
This strategy allows projects to enjoy the convenience of imperative invocation first, and smoothly split files when there's time later, with almost no changes needed to the caller's code. I've also documented the implementation details of this in the docs chapter Container and Content Not Yet Split.
7. Convergence: Why I Say This Shape Was "Pushed Out"
If we lay out all the derivation logic threads from the previous sections side by side:
Invocation written as child component show/hide ──> [Root Cause] Wrong model chosen
Invocation should be like route navigation ──> [Solution] Imperative invocation (open/close)
High-frequency dynamic calls in AI scenarios ──> Even more urgent need for imperative scheduling
Opened content needs reuse/container swap ──> [Solution] Forcibly split container and content (createLayer -> use -> open)
Content enters bubbles and popups in AI scenarios ──> Even more urgent need for container-content split
Three roles present simultaneously ──> [Solution] Must establish four-level config merge orchestration mechanism
Title and basic attributes most tightly bound to content ──> [Solution] Content must be able to configure container (defineLayer)
Multi-person collaboration and rejection of fragmentation ──> [Solution] Abandon JSX for slots, introduce LayerTemplate template delivery
Ensure absolute purity of content components ──> [Solution] Forbid injecting close, rely on closeOn contract wiring
DOM tree drifts outside main app tree ──> [Solution] LayerHost coordinates context, independent createApp accommodates DevTools
Legacy code needs low-cost transition ──> [Solution] Target state unchanged, support monolith sharing the same pipeline as content
Many "shortcut" solutions on the market that I can't buy into have mostly stumbled on one of these links: either forcing MessageBox to prop up business forms; or only caring about open while ignoring context loss and DevTools collapse; or taking the easy route of calling close directly inside content, destroying reusability; or pushing JSX as the main path for slots, causing team collaboration costs to skyrocket.
So, the appearance vue-layerx currently presents — factory functions, Composables, config merging, LayerTemplate, closeOn, bindHost — isn't deliberately crafted to look architecturally elegant, but is the result of the solution space being forced into an extremely narrow corridor when all the above constraints hold simultaneously.
You can change the skin and the name, but the underlying core structure can hardly escape this derived narrow path.
When Don't You Need It?
Since we're discussing architecture, we must honestly draw boundaries; otherwise, it becomes self-indulgent hard-selling. In the following scenarios, you absolutely don't need this tool:
- A simple confirmation box in-place, strongly bound to the current page, with zero reuse aspirations in this lifetime —> Directly using declarative
ElDialog+v-modelis simplest and most intuitive. - Pure prompts or simple confirmations requiring only fixed text —> Native
ElMessageBoxis absolutely the first choice. - The popup itself is a super complex wizard, heavily dependent on the current page's local component tree —> Evaluate first whether it's truly suitable to be modeled as "independent navigation."
vue-layerx's core target is: complex business popups that repeatedly appear in projects, have strong multi-host reuse demands, and need to be invoked with a single command from any call site (including dynamic/async flows). This is daily fare in mid-to-backend systems, and absolutely indispensable in today's AI interaction pages.
Final Words
If this article can leave you with only one core insight, I hope it's this:
Model the "invocation" of popups as route navigation (imperative scheduling), and return the "content" of popups to ordinary business components (declarative rendering).
If you've also suffered from popup boilerplate flying everywhere and components welded solid beyond reuse, try holding the "tax receipts" from this article against your existing solution. The tools can change, but the essential contradictions we face are the same for everyone.
Welcome to the repo and docs for discussion:
- GitHub: vue-layerx
- Online Docs: Design Decisions and Core Guide
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
vue-layerx is a product of collaboration with AI. Most of its concepts and code took shape by the 10th commit, but by the time version 1.0.0 was released, there were nearly 150 commits in total. Every API was repeatedly thought through, polished, and even derived from scratch. In the AI era, all kinds of code seem possible, so why has become more important than how. This article discusses my thinking while building this feature, and I hope it can be helpful to everyone.