Introduction
This documentation outlines a Dynamic Component System built in Vue, which allows you to render UI components ( including PrimeVue components) based on JSON or object-based configurations. Through these configurations, you can bind state, define events, and wire up actions (e.g. fetching data).
This is especially useful when you want to drive parts of your UI from a database, an API, or a CMS. You can build flexible pages or forms by referencing a set of rules or definitions rather than hard-coding every component.
Overview
-
DynamicComponent.vue:
- A core component that takes a
config,globalState, andactionsas props. - It automatically decides which Vue component to render (for example, a PrimeVue
Buttonor a simplediv) based on the configuration. - It also handles prop mappings, model bindings, translations (
t()), and event handlers.
- A core component that takes a
-
DynamicRenderer.vue:
- A higher-level component that:
- Accepts a page-level config (e.g., "pageTitle", "components", "dataQueries").
- Fetches data (if specified) and places it into a reactive
state. - Iterates over each
componentin the config and renders them via theDynamicComponent.
- A higher-level component that:
The configuration typescript classes look like this:
export interface DataQuery {
url: string;
method?: string;
target: string;
}
export interface DynamicComponentConfig {
type: string;
props?: Record<string, any>;
events?: Record<string, string | ((...args: any[]) => void)>;
children?: DynamicComponentConfig[];
slots?: Record<string, DynamicComponentConfig[]>;
}
export interface PageConfig {
id: string;
pageTitle?: string;
dataQueries?: DataQuery[];
actions?: Record<string, unknown>;
components: DynamicComponentConfig[];
}
while PageConfig is the highest possible definition. It is required to match this pattern. These types can be used in configurations to assure the pattern is followed.
How It Works
-
config.type:
Determines which component is rendered. For example, a config like:{
"type": "prime:button",
"props": {
"label": "Click Me"
}
}will load a PrimeVue
Buttoncomponent dynamically. -
config.propsThe props object is passed to the resolved component. Some props might be dynamically bound to values inglobalState. For example:{
"type": "prime:inputtext",
"props": {
"model": "userName"
}
}If
globalState.userNamechanges, the input updates automatically. -
config.events:- Lets you define how to react to component events (e.g.,
click,onHide, etc.). - Each event can reference a function or a named action. For example:
This looks up
{
"type": "prime:button",
"props": {
"label": "Confirm"
},
"events": {
"click": "showConfirmationDialog"
}
}showConfirmationDialogin youractionsobject and calls it.
- Lets you define how to react to component events (e.g.,
-
config.slots(optional):- If the component supports named slots, you can nest entire configurations in them. For example, for a
prime:dialog:{
"type": "prime:dialog",
"props": {
"visible": "confirmationDialog"
},
"slots": {
"footer": [
{
"type": "prime:button",
"props": {
"label": "Close"
},
"events": {
"click": "closeDialog"
}
}
]
}
}
- If the component supports named slots, you can nest entire configurations in them. For example, for a
-
config.children(alternative toslots):- For simpler scenarios, you can define direct children in a
childrenarray. For example:{
"type": "div",
"children": [
{
"type": "h1",
"props": {
"class": "title"
},
"children": [
{
"type": "text",
"props": { "value": "Welcome!" }
}
]
}
]
}
- For simpler scenarios, you can define direct children in a
-
Actions & Global State:
actionsis an object containing either functions or descriptors that can be called from events.globalStateis a single source of truth for your dynamic page, storing user input and fetched data.
Example Configuration
Here’s a hypothetical configuration object that you might pass into DynamicRenderer.vue. This is just an example
to show how you might structure a typical dynamic page:
{
"pageTitle": "home.dashboardTitle",
"dataQueries": [
{
"url": "/api/user",
"method": "GET",
"target": "userData"
}
],
"actions": {
"showConfirmationDialog": {
"type": "fetch",
"url": "/api/confirm",
"method": "POST",
"payloadKey": "userData"
},
"closeDialog": (state) => {
state.confirmationDialog = false;
}
},
"components": [
{
"type": "h2",
"props": {
"class": "text-xl",
"value": "t(greetings.title)"
}
},
{
"type": "prime:button",
"props": {
"label": "t(actions.confirm)",
"class": "p-button-success"
},
"events": {
"click": "showConfirmationDialog"
}
},
{
"type": "prime:dialog",
"props": {
"visible": "confirmationDialog",
"header": "t(dialog.header)"
},
"slots": {
"footer": [
{
"type": "prime:button",
"props": { "label": "Close" },
"events": { "click": "closeDialog" }
}
]
}
}
]
}
What’s Happening Here?
- pageTitle: Used by
DynamicRendererto display a heading ($t(config.pageTitle)). - dataQueries: The system will GET
/api/useron load, storing the result intostate.userData. - actions:
showConfirmationDialogis an action that performs a POST to/api/confirm, sendingstate.userDataas the request body.closeDialogis a simple function that toggles offconfirmationDialog.
- components:
- A header
<h2>that loads a translation key. - A PrimeVue Button that triggers
showConfirmationDialog. - A PrimeVue Dialog that references
confirmationDialogfromstate.
- A header
Integrating in Your Vue App
-
Registration
EnsureDynamicComponent.vueandDynamicRenderer.vueare recognized by your Nuxt or Vue 3 application.- If using Nuxt, place them in the appropriate
/componentsdirectory. - If using standard Vue, import and register them in your main app or in a parent component.
- If using Nuxt, place them in the appropriate
-
Usage
<template>
<DynamicRenderer :config="pageConfig" />
</template>
<script>
import pageConfig from "@/your/configs/page.json";
import DynamicRenderer from "@/components/DynamicRenderer.vue";
export default {
components: { DynamicRenderer },
setup() {
// or define it in data() if using Options API
return { pageConfig };
}
}
</script> -
Translation (optional)
If you have i18n set up,t(home.dashboardTitle)will be replaced with the localized string.
You can embed translations in config strings witht(your.translation.key).
Final Thoughts
- Why: Offload UI definitions to your database or other configurations for ultimate flexibility.
- When: You need a dynamic form or you want users (or admin panels) to specify how the UI should look/behave without redeploying code.
- Extend: You can extend
primeVueMappingor any other mapping so your config can render specialized or custom components.
Reusing JSON for Rendering
Overview
You can define reusable data structures in a separate file (e.g., config.ts in your server or frontend). These structures—like arrays of components, helper functions for generating buttons/links, or entire page configurations—can then be consumed by your Dynamic Renderer to produce fully dynamic UIs. This approach reduces boilerplate, promotes consistency, and keeps your code organized.
Example: createButton and createLink Functions
Suppose you have helper functions in config.ts to build JSON "blueprints" for certain UI elements:
export const createButton: any = (label: string,
rounded: boolean = true,
text: boolean = false,
outline: boolean = false): DynamicComponentConfig => {
return {
"type": "prime:button",
"props": {
"label": label,
"rounded": rounded,
"text": text,
"outline": outline,
"class": ""
}
}
};
export const createLink: any = (to: string,
label: string,
rounded: boolean = true,
text: boolean = false,
outline: boolean = false): DynamicComponentConfig => {
return {
"type": "nuxt-link",
"props": {
"to": to
},
"children": [
createButton(label, rounded, text, outline)
]
}
};
These functions produce JSON objects (which match the format expected by the Dynamic Component or Dynamic Renderer). For instance, createButton("Save") returns a JSON object describing a PrimeVue Button that the renderer can display.
Defining Page Configurations
You can then organize entire pages by combining these reusable snippets:
const pages = [
{
"id": "dashboard",
"components": [
{
"type": "div",
"props": { "class": "w-full" },
"children": [
{
"type": "prime:card",
"props": { "class": "glass-card p-4" },
"slots": {
"header": [
// A helper that creates HTML content
// e.g., a function createHtml() returning {type: 'html', props: {...}}
],
"content": [
// Another helper that adds descriptive text
],
"footer": [
// Reuse createLink to add a link with a button inside
createLink("/entities", "t(action.open)")
]
}
}
]
}
]
}
];
Here, each createXyz helper is invoked to produce the JSON schema for an element. You might have 2–3 different pages, each pulling from the same library of helper functions (like createButton, createLink, etc.).
Benefits of This Approach
- Consistency: By writing helper functions (like
createButtonorcreateLink), you ensure that buttons, links, or cards appear and behave the same throughout your app. - Maintainability: If you need to tweak the styling or functionality of a button, you only update the helper function—no need to rewrite multiple code snippets.
- Scalability: As your app grows, you can add more helper functions (like
createInputField,createForm, etc.) or extend existing ones without cluttering your core logic.
Summary
- Store your dynamic UI blueprints (button shapes, link shapes, page layouts) in a single file (e.g.,
config.ts). - Generate them with small, reusable functions (
createButton,createLink,createHtml, etc.). - Combine them into larger page definitions (
pagesarray), referencing your helper functions. - Render them through
DynamicRenderer, which orchestrates how each piece is turned into an actual Vue/PrimeVue component.
By reusing JSON for rendering, you get a clean separation of data/structure from the rendering logic, making it easier to maintain and expand your app.
Additional Tips
-
Validation:
If you attachprops.validatorsand define avalidateFormaction, the system can automatically track errors inglobalState._formErrors. -
Styling:
- Pass CSS classes or inline styles in the config (
"props": { "class": "my-class", "style": "color: red;" }). - Or rely on your existing Tailwind/utility classes in the config.
- Pass CSS classes or inline styles in the config (
-
Slots vs. Children:
- Use slots if the base component supports named slots (e.g.
<footer>in PrimeVue Dialog). - Use children if you simply want nested elements in the same default slot.
- Use slots if the base component supports named slots (e.g.
That’s it! With this approach, you can define entire pages, forms, or dynamic sections with just JSON or object-based definitions and leverage all the power of Vue + PrimeVue behind the scenes.