DEV Community

Cover image for Data-driven UIs
Charles F. Munat for Craft Code

Posted on • Originally published at craft-code.dev

Data-driven UIs

DDUIs mean a single source of truth and the benefits that come with it.

Building on a previous article, DOM to JSON and back, we now move on to data-driven UIs.

A data-driven UI is a user interface generated dynamically from a query response. In short, the database schema determines the interface to that data.

This gives us a single source of truth. Our interface (e.g., forms) cannot get out of synch with our data source. If we update the database, then the interface updates itself instantly.

Key takeaway

Here is how things are usually done: we build a database with a schema.

Then we build an API that sits in front of it. Then we build an interface to that API. All hard-coded.

Have fun keeping them in synch.

What we propose here is to extend the database schema. We add enough information to it to allow us to generate the API and UI dynamically. Then we generate them on the fly.

This means we have one source of truth. When that source changes, the interface updates itself instantly to match it.

We call this a data-driven UI. It rocks.

On this page

  1. Start at the finish
  2. Widget by widget
    1. ID
    2. Name
    3. Email address
    4. Display name on profile
    5. The “Save changes” button
  3. Putting it all together
    1. Adding HATEOAS
  4. Oh, the benefits!
    1. Whatʼs next?

Start at the finish

To determine what we need to add to our database schema and query response, letʼs start with the interface. Thatʼs where the rubber meets the road, right? Weʼll keep it simple.

How about a form used to update a userʼs profile? Something like this:

<form action="#" id="edit-profile" method="POST">
  <input type="hidden" name="_charset_">
  <input type="hidden" name="id" value="255c08b2-6606-424b-a339-d3f9ebe50a21">
  <div class="text-field">
    <label for="name">Name</label>
    <input id="name" name="name" required type="text">
  </div>
  <div class="email-field">
    <label for="email">Email address</label>
    <input id="email" name="email" required type="email">
  </div>
  <div class="boolean-field">
    <label for="display-name-on-profile">
      <input
        id="display-name-on-profile"
        name="displayNameOnProfile"
        type="checkbox"
        value="true"
      >
      Display email on profile
    </label>
  </div>
  <fieldset class="member-picker">
    <legend>Favorite color</legend>
    <label for="gray">
      <input
        checked
        id="gray"
        name="favoriteColor"
        type="radio"
        value="#999"
      >
      gray
    </label>
    <label for="red">
      <input
        id="red"
        name="favoriteColor"
        type="radio"
        value="#f00"
      >
      red
    </label>
    <label for="green">
      <input
        id="green"
        name="favoriteColor"
        type="radio"
        value="#0f0"
      >
      green
    </label>
    <label for="blue">
      <input
        id="blue"
        name="favoriteColor"
        type="radio"
        value="#00f"
      >
      blue
    </label>
  </fieldset>
  <div class="button-bar">
    <button type="submit">Save changes</button>
  </div>
</form>
Enter fullscreen mode Exit fullscreen mode

What did we need to know to code this form?

First, we need to know what data it will include and the datatype of each:

  • The userʼs ID, which is of type UUID.
  • The userʼs name, which is a character string. This might have some limitations, such as characters allowed or limits on length.
  • The userʼs email address, which is of type EmailAddress. This type does not yet exist, so we will create it.
  • A boolean flag to decide whether to display the userʼs email address on their profile or leave it off.

Widget by widget

Now letʼs consider what else we need to know.

ID

We know that the user cannot update their ID, so this is readonly. Should the user see this? It would only add to the clutter. So letʼs make it hidden.

We donʼt care what type the ID is because we are going to return it unchanged. That said, we know that it is a UUID.

From the above, we can see that the proper widget for the ID is an input of type hidden. We can treat the UUID as a string.

Name

We know that the user can update their name, so this field is mutable. And visible. And itʼs a string.

A short string, so that means an input of type text rather than a textarea.

Email address

We also know that the user can update their email address. And verify it by clicking on a link in an email sent to that address. It should be mutable and visible and of type EmailAddress, hence an EmailField.

That gives us a semantic advantage. The browser can do validation for us. Or we can add our own, but use the browserʼs validation as a fall back.

We also need to set the name and email address to required.

Display name on profile

This field requires a simple yes or no. Does the user want to display their email address on their profile?

The type, then, is boolean, and the best way to collect this data point is an input of type checkbox.

If we do this correctly in the database, then the type is a BOOLEAN type. So the value is either TRUE or FALSE. We can build our component so that it works with this type.

Does it matter that the specific widget is a checkbox? No. So we call this widget a BooleanField.

One important distinction of a DDUI is that it is schema-centric. We donʼt care what widget we use. Thatʼs up to the interface. We care about the datatype.

That is why we call this a BooleanField instead of a “CheckboxField“. It may seem trivial, but precise language is very important. It helps to re-orient oneʼs brain toward a DDUI approach.

Favorite color

Now we are asking the user to make a choice from a set of options. The key word here is set. In short, we are asking the user to choose one member from that set.

The widget for this is then a MemberPicker because it permits one to choose a single member from a set. If we were allowing multiple selections, then weʼd have a SubsetPicker (or Chooser or whatever).

Items in a set are also called elements. But “ElementPicker“ might be confusing as we are also working with HTML elements.

In this instance, our set has four members, each with a label and a value:

  • gray: #999
  • red: #f00
  • green: #0f0
  • blue: #00f

Besides the favoriteColor, we will need this full set of color options. The back end will have to provide these, but theyʼd be in the HTML anyway.

What widget should we choose? Well, we have options. We could use a select element. But for only four choices, it would be nice to see all at once. That means inputs of type radio.

But see below.

The “Save changes” button

We need some way to trigger the action that saves our changes. There are several possibilities, but easiest is to wrap our inputs in a form. And add a submit button.

And here comes Roy Fielding's “Hypertext As The Engine Of Application State”: HATEAOS. Our server needs to tell us which actions are available for Profile.

When we load our example profile page, the user agent (browser) makes an HTTP request to the server. The server sends an HTML document. This links to various other documents: images, stylesheets, scripts, etc.

In our scenario, this page then does an AJAX request to retrieve the data.

On most pages, the HTML form is already in place. We use JavaScript to enter into the form using JavaScript. But in our case, with a DDUI app, the form does not exist until the AJAX requests returns. Then JavaScript both builds the form and fills it with the data.

As the form is not hard coded, we donʼt know where to submit it. What is the formʼs action? Is it a GET or a POST? So we need this metadata in our AJAX response.

This has the added benefit of making the API discoverable.

Putting it all together

So here is a first pass at our current schema:

{
  "type": "Profile",
  "properties": [
    {
      "name": "id",
      "type": "uuid",
      "readonly": true,
      "hidden": true
    },
    {
      "name": "name",
      "type": "string",
      "required": true
    },
    {
      "name": "email",
      "type": "EmailAddress",
      "required": true
    },
    {
      "name": "displayNameOnProfile",
      "type": "boolean",
      "default": true
    },
    {
      "name": "favoriteColor",
      "type": "Member",
      "default": "gray",
      "options": [
        {
          "label": "gray",
          "value": "#999"
        },
        {
          "label": "red",
          "value": "#f00"
        },
        {
          "label": "green",
          "value": "#0f0"
        },
        {
          "label": "blue",
          "value": "#00f"
        },
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Easy peasy, right?

Our ID is not visible, so we know to use a hidden field for it. So:

  • Name is a StringField. We use an input of type text.
  • Email is an EmailField. This uses an email input.
  • “displayNameOnProfile” is a BooleanField: an input of type checkbox.
  • And “favoriteColor” is a MemberPicker. In this instance, a set of inputs of type radio.

We get all this from the query response and the schema. We no longer need to hard code the form.

We also know to:

  1. Default displayNameOnProfile to checked (true)
  2. Set our default favoriteColor to gray

And we know that the whole thing is the Profile type.

We could return this schema parallel to the query response containing our values. But why not insert the current values and return them with our schema?

Our Profile schema with current values:

{
  "type": "Profile",
  "properties": [
    {
      "name": "id",
      "type": "uuid",
      "readonly": true,
      "hidden": true,
      "value": "255c08b2-6606-424b-a339-d3f9ebe50a21"
    },
    {
      "name": "name",
      "type": "string",
      "required": true,
      "value": "Bob Dobbs"
    },
    {
      "name": "email",
      "type": "EmailAddress",
      "required": true,
      "value": "bob@dobbs.guru",
      "verified": true
    },
    {
      "name": "displayNameOnProfile",
      "type": "boolean",
      "default": true,
      "value": false,
    },
    {
      "name": "favoriteColor",
      "type": "Member",
      "default": "gray",
      "options": [
        {
          "label": "gray",
          "value": "#999"
        },
        {
          "label": "red",
          "value": "#f00"
        },
        {
          "label": "green",
          "value": "#0f0"
        },
        {
          "label": "blue",
          "value": "#00f"
        },
      ],
      "value": "blue"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

OK, now what can we do with this?

Adding HATEOAS

As mentioned above, we need to include a set of available actions. For our Profile, these are CRUDL: create, retrieve, update, delete, and list. Letʼs put them in an actions property and add them to our Profile schema:

{
  "actions": {
    "create": {
      "method": "PUT",
      "url": "/profiles/{id}"
    },
    "retrieve": {
      "method": "GET",
      "url": "/profiles/{id}"
    },
    "update": {
      "method": "PATCH",
      "url": "/profiles/{id}"
    },
    "delete": {
      "method": "DELETE",
      "url": "/profiles/{id}"
    },
    "list": {
      "method": "GET",
      "url": "/profiles"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Of course, we may not permit all these actions all the time. We have to consider authorization as well. But then we simply leave them off the actions list.

There is much more that we can do with this! For example, we can un-camelCase the name property to get the field label. Better, letʼs default to that, but use a label property to override the name where needed.

We can also add more data to our actions. For example, a label for the button (defaulting to “create”, etc.). Or a description of what the action does. Or limits on the action, such as a Duration that disables it except during that duration. (Or the reverse.)

The possibilities are endless.

Oh, the benefits!

The most important benefit of a DDUI is the one mentioned at the top of this essay: a single source of truth. But there is so much more.

This approach requires much less effort. Devs donʼt hard code the same set of components over and over again. Instead, they maintain the jsonToDom mapping. Once we build this renderer, we need only to add occasional new options.

Your database schema then represents two things:

  • Your domain
  • Metadata to help configure the interface

Your database already knows the datatype of everything you persist. Why not clue in the front end? We can also include metadata to permit us to configure the front end.

For example: we used radio buttons for our MemberPicker. But what if there are fifty options? Then weʼd likely want a select element instead. Or some kind of “typeahead” widget. Our MemberPicker widget will output any of these, but how does it know which one to use?

One way is to set the type of widget in the query response, but thatʼs a bad idea. The schema should have no knowledge or interest in how we present these data.

  1. For one to seven options, use radio buttons.
  2. For eight to thirty-two options, use a select element.
  3. For thirty-three or more options, use a lookahead field.

We could hard code this into the widget, but why not load a configuration when the app starts up?

We can also run this on the server side if we prefer. Or generate partial HTML to create a static site and then do the rest on the client side. Kind of how React used to work.

Now, when we need to change the interface, we donʼt have to create the race condition we usually get. Thatʼs where the back and and front end devs block each other.

We also donʼt have to make changes in three different places. With the virtual certainty that they will get out of synch and create problems. Instead, we update the database once and presto! The front end updates itself instantly.

Here is our final simple DDUI schema for Profile:

{
  "type": "Profile",
  "properties": [
    {
      "hidden": true,
      "name": "id",
      "readonly": true,
      "type": "uuid",
      "value": "255c08b2-6606-424b-a339-d3f9ebe50a21"
    },
    {
      "name": "name",
      "required": true,
      "type": "string",
      "value": "Bob Dobbs"
    },
    {
      "label": "Email address",
      "name": "email",
      "required": true,
      "type": "EmailAddress",
      "value": "bob@dobbs.guru",
      "verified": true
    },
    {
      "default": true,
      "name": "displayNameOnProfile",
      "type": "boolean",
      "value": true,
    },
    {
      "default": "gray",
      "name": "favoriteColor",
      "options": [
        {
          "label": "gray",
          "value": "#999"
        },
        {
          "label": "red",
          "value": "#f00"
        },
        {
          "label": "green",
          "value": "#0f0"
        },
        {
          "label": "blue",
          "value": "#00f"
        },
      ],
      "type": "Member",
      "value": "blue"
    }
  ],
  "actions": {
    "create": {
      "method": "PUT",
      "url": "/profiles/{id}"
    },
    "retrieve": {
      "method": "GET",
      "url": "/profiles/{id}"
    },
    "update": {
      "method": "PATCH",
      "url": "/profiles/{id}"
    },
    "delete": {
      "method": "DELETE",
      "url": "/profiles/{id}"
    },
    "list": {
      "method": "GET",
      "url": "/profiles"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The above JSON, when run through our renderer function, will generate the HTML below. This is an update form.

<form
  action="/profiles/255c08b2-6606-424b-a339-d3f9ebe50a21"
  data-method="PATCH"
  id="edit-profile"
  method="POST"
>
  <input type="hidden" name="_charset_">
  <input
    type="hidden"
    name="id"
    value="255c08b2-6606-424b-a339-d3f9ebe50a21"
  >
  <div class="text-field">
    <label for="name">Name</label>
    <input
      id="name"
      name="name"
      required
      type="text"
      value="Bob"
    >
  </div>
  <div class="email-field">
    <label for="email">Email address</label>
    <input
      id="email"
      name="email"
      required
      type="email"
      value="bob@dobbs.com"
    >
  </div>
  <div class="boolean-field">
    <label for="display-name-on-profile">
      <input
        checked
        id="display-name-on-profile"
        name="displayNameOnProfile"
        type="checkbox"
        value="true"
      >
      Display email on profile
    </label>
  </div>
  <fieldset class="member-picker">
    <legend>Favorite color</legend>
    <label for="gray">
      <input
        id="gray"
        name="favoriteColor"
        type="radio"
        value="#999"
      >
      gray
    </label>
    <label for="red">
      <input
        id="red"
         name="favoriteColor"
        type="radio"
        value="#f00"
      >
      red
    </label>
    <label for="green">
      <input
        id="green"
        name="favoriteColor"
        type="radio"
        value="#0f0"
      >
      green
    </label>
    <label for="blue">
      <input
        checked
        id="blue"
        name="favoriteColor"
        type="radio"
        value="#00f"
      >
      blue
    </label>
  </fieldset>
  <div class="button-bar">
    <button type="submit">Save changes</button>
  </div>
</form>
Enter fullscreen mode Exit fullscreen mode

Whatʼs next?

Look for more detailed articles on this topic in the future. With plenty of code examples. Even some npm (or JSR) libraries.

But the most exciting part of this is the core of the whole system: the database. We need a database that will permit us to provide infinite detail about our schema. And keep the schema strict. And permit us to update it on-the-fly whenever we want to.

And we know just the right type of database for this purpose.

More soon.

Top comments (0)