Creating Server-Driven UI Apps

In part 1 and part 2 of my Server-Driven UI series, I’ve described why you should care and how to create a server that implements the business logic and defines the views to render. Now comes the fun part: it’s time to create iOS and web apps that render these views.

Please note. I’ve added a lot of sample code to clarify the concepts in this post. This code cannot be copied as-is and be expected to work. I may release a working repository in the future. Let me know if that would be interesting.

Components

Now that the server is responsible for communicating with any backend system we may need, deciding what to show the user and everything else that may be considered business logic, it’s time to show this to the users somehow. This UI starts with the components.

Earlier in the series, I pointed out that the React code we move to the server cannot contain any HTML. Instead, we should create a component library with all the components we want to have available.

How you implement the components is totally up to you. We’ve chosen to go with React, SwiftUI, and Compose, but if you prefer React Native, Flutter, or something else, go for it. What’s important is that you can compose your components the same way on every platform. The components can look and work differently but must be composable the same way.

To illustrate, here is an example of how our example screen can look in React and SwiftUI.

React

function AccountsScreen({
  totalDebt, totalSavings, creditCardAccounts, savingsAccounts
}: Props) {
  return (
    <List title="Accounts">

      <WidgetListItem>
        <Widget
          icon={Icon.ArrowDown}
          label="Total Debt"
          value={totalDebt}
        />
        <Widget
          icon={Icon.ArrowUp}
          label="Total Savings"
          value={totalSavings}
        />
      </WidgetListItem>

      {creditCardAccounts && (
        <ListSection title="Credit Card Accounts">
          {creditCardAccounts.map((account) => (
            <DataListItem
              icon={Icon.Card}
              label={account.name}
              value={account.debt}
              url={`/accounts/${account.accountNumber}`}
            />
          ))}
        </ListSection>
      )}

      {savingsAccounts && (
        <ListSection title="Savings Accounts">
          {savingsAccounts.map((account) => (
            <DataListItem
              icon={Icon.Money}
              label={account.name}
              value={account.balance}
              url={`/accounts/${account.accountNumber}`}
            />
          ))}
        </ListSection>
      )}
    </List>
  )
}

SwiftUI

struct AccountsScreen: View {
  let totalDebt: String
  let totalSavings: String
  let creditCardAccounts: [CreditCardAccount]
  let savingsAccounts: [SavingsAccount]

  var body: some View {
    ListView(title: "Accounts") {

      WidgetListItemView {
        WidgetView(
          icon: .arrowDown,
          label: "Total Debt",
          value: totalDebt
        )
        WidgetView(
          icon: .arrowUp,
          label: "Total Savings",
          value: totalSavings
        )
      }

      if !creditCardAccounts.isEmpty {
        ListSectionView(title: "Credit Cards Accounts") {
          ForEach(creditCardAccounts) { account in
            DataListItemView(
              icon: .card,
              label: account.name,
              value: account.debt,
              url: "/accounts/\(account.accountNumber)"
            )
          }
        }
      }

      if !savingsAccounts.isEmpty {
        ListSectionView(title: "Savings Accounts") {
          ForEach(savingsAccounts) { account in
            DataListItemView(
              icon: .money,
              label: account.name,
              value: account.balance,
              url: "/accounts/\(account.accountNumber)"
            )
          }
        }
      }
    }
  }
}

When rendered on the web and iOS using a component library, these two app screens may look like this.

Fetching data

Once we’ve implemented our components, we must fetch the data from the server as we normally do. The important thing is to decode the response into the ComponentDefinition type we saw in part 2.

type ComponentDefinition = {
  name: string
  props?: any
  children?: ComponentDefinition[]
  key: string
}

We need to do the same in Swift, but as you can see, the props attribute has the type any in TypeScript. Decoding into any cannot be done in Swift, so instead, we create a JsonObject type to capture the dynamic nature of the props. It’s not straightforward, but you can find an implementation by searching for it.

struct ComponentDefinition: Codable, Identifiable {
  var name: String
  var props: JsonObject
  var children: [ComponentDefinition]?
  var key: String

  var id: String { key }
}

Creating components

The next step is to use the definitions we’ve fetched and decoded from the server and turn them into actual components to recreate the screens we implemented earlier.

The React code to do this may look intimidating, but if you look closer, you will see all we do is find the component based on its name and then create it. We need to go depth-first by traversing its children, but we don’t need to do anything special when we add new components.

import * as components from "./library"

let componentCreators: Record<string, FunctionComponent<any>> = components

function ComponentRenderer({ component }: Props) {

  function render(componentDefinition: ComponentDefinition): ReactNode | null {

    let children: ReactNode[] | ReactNode | null =
      componentDefinition?.children?.map(render)

    if (Array.isArray(children)) {
      if (children.length === 1) {
        children = children[0]
      } else {
        children = Children.toArray(children) // needed to auto add keys
      }
    }

    const ComponentCreator = componentCreators[componentDefinition?.name]
    if (ComponentCreator !== undefined) {
      return (
        <ComponentCreator
          key={componentDefinition.key}
          {...componentDefinition.props}
        />
      )
    } else {
      throw Error(`Component '${componentDefinition.name}' not found`)
    }
  }

  return component === undefined ? null : render(component)
}

Again, Swift is a bit more complicated due to its static nature, so in this implementation, we add a new renderer for every new component. We can do this much smarter and more typesafe, but this will do the trick for now.

struct ComponentRendererView {
  let component: ComponentDefinition?

  var body: some View {
    let view = if let component {
      switch component.name {
        case "DataListItem": renderDataListItem(component)
        case "List": renderList(component)
        case "ListSection": renderListSection(component)
        case "Widget": renderWidget(component)
        case "WidgetListItem": renderWidgetListItem(component)

        default: Text("Component '\(component.name)' not found")
      }
    } else {
      return AnyView(EmptyView())
    }
  }

  // Helpers

  @ViewBuilder
  func renderDataListItem(component: ComponentDefinition) -> some View {
    AnyView(
      DataListItemView(
        icon: component.props["icon"],
        label: component.props["label"],
        value: component.props["value"],
        url: component.props["url"]
      )
    )
  }

  @ViewBuilder
  func renderList(component: ComponentDefinition) -> some View {
    AnyView(
      ListView(title: component.props["title"]) {
        ForEach(component.children) { child in
          ComponentRendererView(component: child)
        }
      }
    )
  }

  ...
}

Putting it all together

The final step is to put everything together. To do this, we create a ServerContainer component. This is responsible for fetching the data, holding the component definition, and passing it to the component renderer as the data changes.

React

function ServerContainer({ url }: Props) {
  const [component, setComponent] = useState<ComponentDefinition | undefined>()

  async function load(url: string) {
    const component = await httpClient.get(url)
    setComponent(component)
  }

  useEffect(() => {
    load(url)
  }, [url])

  return <ComponentRenderer component={component} />
}

SwiftUI

struct ServerContainerView: View {
  let url: String

  @State component: ComponentDefinition?

  var body: some View {
    ComponentRendererView(component: component)
      .task { component = await HttpClient().get(url)}
  }
}

Designing your components

One thing that you should give a lot of thought to if you want to give this a try is how to design your components. I’m not talking about UI design here, but rather how to size your components, which props to expose, and how your components should be composable to fit on all platforms.

This is the most complicated part of this entire approach.

You could create components such as <Button>, <H1>, <H2>, <Container>, and so on, and even expose color and font props. Some frameworks do this, giving a lot of control to the server, but it leaves little room for apps to adjust the UI to fit on the platform. Android often uses the floating action button; iOS has actions in the toolbar and so on. The platforms are different, and if we want our apps to feel native, we need to acknowledge that.

The other extreme is to create <LoginScreen> and <AccountsScreen> components. Now, it’s very flexible on the client side but extremely inflexible on the server side.

You should probably be somewhere between these extremes but which side your should lean on depends on your team’s preferences.

Conclusions

In this post, we’ve created a component we can use anywhere in our apps. You pass it a URL, and it will render whatever comes from the server and use native components for each platform to do so. This is a great way to start, especially if you have a complex screen that you know will change over time.

Is it worth it?

That’s for you to decide. In these three posts, I’ve only shown how to render read-only content, but for our project, we go further. The server can return nested server containers; we added routing and an action system to let the user and the server trigger actions.

So, for us, it’s worth it. We can drive our entire app from the server and change it at any time, any way we want, and it’s pretty awesome.

It has its challenges though. I will probably write more about how we’ve solved actions, performance, and other challenges in the future, but this will have to be enough for now.

I hope you’ve enjoyed the series.