Composable UI Architecture Using a DSL

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.