In modern front-end development, particularly in React applications, there’s a growing emphasis on creating maintainable, scalable, and modular user interfaces. This article explores how a composable UI architecture can be implemented using a low-code DSL (Domain-Specific Language), enabling developers to define structure, behavior, and layout through clear, declarative configuration.
We will cover:
- The core concepts of Views, ViewModels, Screens, Components, and Blueprints
- Asynchronous component loading strategies
- Dynamic layouts and slot-based UI composition
- Context management and performance
- Translation patterns using implicit keys
- DSL vs JSON syntax comparisons for structure
- Extensibility using transformation providers (e.g., Puck integration)
Understanding Views, ViewModels, Screens, Components, and Blueprints
What is a View?
A View is a pure rendering function in React. It is concerned only with displaying UI based on the props it receives. Views are typically stateless and are highly reusable.
function AssetView({ state, data, actions }) {
return <div>{data.name}</div>;
}
state
: Represents temporary or UI-specific values (e.g. form state, active tab).data
: Contains persistent or fetched information (e.g. API results, records).actions
: A set of callbacks for user interaction (e.g. save, cancel).
Example:
const state = { activeTab: 'details', selectedRecordId: 1001 }; // UI-driven
const data = { asset: { name: 'Pump 101', status: 'active' } }; // From API
What is a ViewModel?
A ViewModel is a custom hook that encapsulates logic, data fetching, and interaction logic. It returns the exact inputs needed by a View.
export function useAssetViewModel() {
const [state, setState] = useState({});
const data = useDataFetching();
return {
state,
data,
actions: { save, cancel },
};
}
What is a Screen?
A Screen is a composition layer that connects a View with its ViewModel and is typically associated with a route or page in the application. Screens are loaded via navigation and follow conventions for dynamic loading.
function AssetScreen() {
const vm = useAssetViewModel();
return <AssetView {...vm} />;
}
What is a Component?
A Component is similar to a screen but is not route-aware. Components are reusable building blocks used within other screens or components. Unlike screens, they are typically loaded via direct imports or DSL-defined composition rather than through navigation.
function SummaryComponent() {
const vm = useSummaryViewModel();
return <SummaryView {...vm} />;
}
Components can be nested and reused across the UI, often with more explicit inclusion (e.g., within a slot or layout region).
What is a Blueprint?
A Blueprint (in DSL terms) is a declarative representation of a screen or component’s structure. It specifies which blocks (e.g. views + logic) should appear, what parameters they use, and how they are connected.
Views and view models are reusable.
You can use the same view with different view models.
You can also use the same view model with different views.
The blueprint defines the relationship between what view and view model to use.
PARAMETERS:
selectedRecord: EMPTY
BLOCKS:
KEY: info
VIEW: AssetInfoView
VIEW_MODEL: AssetInfoViewModel
PARAMETERS: [selectedRecord]
Async Loading Per Component (Isolation Strategy)
Each component manages its own loading behavior:
function SomeComponent() {
const { isLoading, data } = useMyViewModel();
return isLoading ? <LoadingSkeleton /> : <SomeView data={data} />;
}
Pros:
- Lightweight and predictable
- Efficient memory usage
- No need for global Suspense boundaries
Dynamic UI Using DSL (and JSON Alternative)
Instead of hardcoding screen layout, define it using a DSL or JSON. Here’s how a layout can be described in both:
DSL Example
PARAMETERS:
selectedRecord: EMPTY
BLOCKS:
KEY: info
VIEW: AssetInfoView
VIEW_MODEL: AssetInfoViewModel
PARAMETERS: [selectedRecord]
JSON Equivalent
{
"parameters": {
"selectedRecord": null
},
"blocks": [
{
"key": "info",
"view": "AssetInfoView",
"viewModel": "AssetInfoViewModel",
"parameters": ["selectedRecord"]
}
]
}
Dynamic imports resolve modules by convention:
const view = await import(`@/views/${block.VIEW}.jsx`);
const viewModel = await import(`@/viewModels/${block.VIEW_MODEL}.jsx`);
Pros:
- Clear separation of structure and logic
- Dynamic, scalable, memory-efficient
Cons:
- Requires careful handling of dynamic imports
Conditional Rendering
(Roles, Flags)
The DSL supports conditions using a clean WHEN ... THEN ... ELSE
syntax:
SLOTS:
sidebar:
WHEN @user.role = "admin" THEN:
TEMPLATE: ~AdminSidebar
ELSE:
TEMPLATE: ~UserSidebar
Pros:
- Reuses the same condition engine used across the DSL
- Readable, concise, expressive
Shared Parameters
(State Bridging Without Context)
Parameters at the screen level are available to blocks that declare them:
PARAMETERS:
selectedRecord: EMPTY
BLOCKS:
KEY: grid
PARAMETERS: [selectedRecord]
ACTIONS: [setSelectedRecord]
KEY: graph
PARAMETERS: [selectedRecord]
These are passed into ViewModels:
function useGraphViewModel({ selectedRecord }) {
const data = useFetchGraphData(selectedRecord.id);
return { data };
}
Translations Using Implicit Keys
All LABEL
values in the DSL are assumed to be translation keys. If the key doesn’t resolve, it is used as-is.
DSL
LABEL: form.title
Implementation
function translate(key) {
return cache[key] || fetchAndCache(key) || key;
}
Frequently used keys like “Save” or “Cancel” are cached persistently, while others are loaded on-demand.
Pros:
- Minimal DSL clutter
- Efficient, lazy and reusable
Slot-Based UI Composition
Slots allow for structured dynamic layouts using templates, parameters, and conditions.
DSL Example
SLOTS:
graph:
WHEN @selectedRecord IS NOT EMPTY THEN:
TEMPLATE: ~GraphBlock
PARAMETERS:
selectedRecord: @selectedRecord
ELSE:
TEMPLATE: ~EmptyState
JSON Equivalent
{
"slots": {
"graph": {
"when": "@selectedRecord IS NOT EMPTY",
"then": {
"template": "~GraphBlock",
"parameters": {
"selectedRecord": "@selectedRecord"
}
},
"else": {
"template": "~EmptyState"
}
}
}
}
Pros:
- Enables layout-level composition
- Slots can resolve async by default
- Controlled entirely via DSL or config
Extending with Providers and Transpilers
The DSL is extensible and can support external formats. For example, the Puck Editor saves layouts as JSON objects. While powerful, the Puck format can be verbose and hard to maintain manually.
Instead, a transformation provider can be implemented, transpiling Puck JSON into this DSL format, making it more readable and customizable.
Use Case Example:
- Puck saves layout as:
{
"content": [
{
"type": "HeadingBlock",
"props": {
"id": "HeadingBlock-1234",
"title": "Hello, world"
}
}
],
"root": {},
"zones": {
"HeadingBlock-1234:my-content": [
{
"type": "TextBlock",
"props": {
"id": "TextBlock-5678",
"text": "Welcome to our site!"
}
}
]
}
}
A DSL provider could convert this into:
TEMPLATES:
HeadingBlock-1234:
PARAMETERS:
title: "Hello, World"
DIV:
id: "TextBlock-5678"
content: "Welcome to our site!"
This makes it easier to:
- Apply consistent logic and styles
- Embed into slots and screens
- Extend with DSL features like conditions, async data, or parameters
Pros:
- Encourages reuse and visual authoring
- Keeps production config clean and expressive
Summary
This architecture provides a clean and extensible approach to building dynamic, low-code-driven UIs:
- Views render UI using passed-in state, data, and actions
- ViewModels handle logic and behavior as hooks
- Screens compose views + viewmodels and are tied to navigation
- Components behave like screens but are composed directly, not routed
- Blueprints (DSL) define structure, layout, slots, and parameters
- Providers can transform external formats like Puck JSON into this DSL
- Translation and async behaviors are built-in by design
Whether you’re building enterprise low-code tooling, multi-tenant SaaS apps, or CMS-driven products, this pattern offers clarity and power.
This guide uses a custom DSL, but the same architecture can also be defined using JSON for compatibility with external systems.