BT

Facilitating the Spread of Knowledge and Innovation in Professional Software Development

Write for InfoQ

Topics

Choose your language

InfoQ Homepage Articles Functional UI (Framework-Free at Last)

Functional UI (Framework-Free at Last)

Bookmarks

Key Takeaways

  • User interfaces are reactive systems that are specified by the relation between the events received by the user interface application and the actions the application must undertake on the interfaced systems
  • Popular UI frameworks (like React, Vue, or Angular) often come with a high accidental complexity, with state and effects scattered piecemeal across a component tree, and god components handling a large number of unrelated concerns
  • Functional UI is a set of implementation techniques for user interface applications that emphasizes clear boundaries between the effectful and purely functional parts of an application
  • Functional UI is conceptually simple, more directly reflects the application's specifications, relegates the UI framework to being a mere library, allows developers to unit-test user scenarios, and leads to applications with fewer design and implementation bugs.
  • Functional UI optimizes for correctness while creating options for developers to later revisit key choices such as the UI framework or the remote data fetching mechanism when information accrues.

Why functional UI?

As the name suggests, user interfaces allow a user to interface with other systems, with the idea that this interface will present some sought-for advantages vs. direct interaction with the mentioned systems. The user expresses an intent through some input modality (such as a keypress, or vocal entry) and the user interface reacts by realizing predefined actions on the interfaced systems. User interfaces are reactive systems almost by nature. Any specification technique for user interfaces must then detail the correspondence between user interface inputs and actions on interfaced systems, i.e. specifications for the behavior of the application. A user story hence can be specified in terms of the series of events initiated by the user or accepted by the application, and the corresponding expected reactions of the system.

Many frameworks for implementing user interfaces (Angular2, Vue, React, etc.) make use of callback procedures, or event handlers, which, as a result of an event, directly perform the corresponding action. Deciding which action to perform (be it input validation, local state update, error handling, or data fetching) often means accessing and updating some pieces of state that are not always in scope. Frameworks thus include some state management or communication capabilities to handle delivering state where it is needed and updating it when allowed and required.

Component-based user interface implementations generally feature pieces of state, and actions scattered along the component tree in non-obvious ways. For instance, a todo list application may be written as <TodoList items><TodoItem></TodoList>. Assuming a TodoItem manages its deletion, it has to communicate the deletion up the hierarchy for the parent TodoList to be called with the updated item list. Assuming instead it is the parent TodoList that manages the deletion of its items, it may nevertheless have to communicate the deletion to the child TodoItem (maybe to execute some clean-up actions).

The bottom line is that to match actions to a given event, it is necessary to look into each component implementation to understand the event and actions it handles, together with the messaging protocol it engages in with its dependent components in the component tree, and then repeat the same process with the dependent components till an independent component is reached. Only then can a complete list of actions triggered by an event be produced. Furthermore, components are often specific to a given framework and restrict options to what is available in that framework.

The framework of choice, however, is an implementation detail that is disconnected from the specifications. The specific shape of the component tree implementing the application and inter-component messaging is also largely orthogonal to the specifications. As a result, answering the question What happens when the user follows a user story, i.e. when the application receives a given sequence [X, Y, ...] of events, requires taming the incidental complexity generated by the framework idiosyncrasy; and its component, state management, and communication mechanisms.

And yet without answering this question, one cannot be confident that an implementation conforms to the specifications, which is the raison d'être of the software in the first place. That confidence only goes down when the number and size of user stories increases.

Functional UI techniques, on the other hand, seek to directly reflect the user interface’s specifications, by deriving functional equations from the event/actions correspondence. Because the equations are directly derived from the specifications, it is possible to reach an implementation that is close to the specification. This generally results in less room for implementation bugs, and specification bugs get found earlier in development. Because functional UI relies on pure functions, user stories can be unit-tested easily, reliably, and quickly. In some cases (state machine modeling), implementation and tests can even be generated with a high degree of automation. Because functional UI is just standard functional programming, it does not rely on any framework magic. Functional UI will interface well with any UI framework, or no framework if that is preferred.

The present article will introduce what is meant by functional UI, the fundamental functional equation behind it, concrete examples of usage of the technique, and how to test applications written in that style. While doing so, the article will strive to surface the advantages and disadvantages of a functional UI approach to web applications.

But what is functional UI?

Any user interface application implements, implicitly or explicitly:

  1. an interface by which the application receives its events
  2. a relation ~ between events and actions such that event ~ action, where
    • ~ is called here the reactive relation
    • event is an event received through the user interface and triggering an action on the interfaced systems. Events can be
      • user-initiated (like button clicks)
      • system-initiated i.e. generated by the environment or external world (like API responses)
  3. an interface with the external systems through which actions intended by the user must be performed

Because most reactive systems are stateful, the relation ~ is not, in general, a mathematical function (which associates one and only one output to one input). A simple stateful UI application is a toggle button. On one button press, the application will render a toggled button. On the next button press, the application will render an untoggled button. As the same user event leads to a different rendering action on the interfaced output device (screen), the application is stateful and it is not possible to define a mathematical function such that action = f(event).

We call functional UI a set of implementation techniques for user interface applications that emphasizes:

  • a separation of event representation from event dispatching;
  • a separation of action representation from action execution; and
  • an explicit, pure function relating actions performed by the application to events received by the application (the reactive function).

Functional UI thus isolates the effectful parts of the application (dispatching events, running effects), and links them up with pure functions. As a result, functional UI naturally leads to a layered architecture, in which each layer only interfaces with the adjacent layers. The simplest layered architecture is made of three layers and can be represented as follows:

The command handler module is in charge of executing the commands it receives through the programming interface defined by each interfaced system. The interfaced systems may send the responses to the previous API calls as events to the command handler. Interfaced systems may also send events to the application through a dispatcher. This is typically the case for the DOM, which is updated as the result of a rendering command; and contains event handlers that limit themselves to dispatching events.

With this conceptual framework established, let’s introduce the fundamental equation enabling functional UI implementations.

The fundamental equation of reactive systems

Most of the time, it is possible to formalize a state for a reactive system such that: (action, new state) = f(state,event) where:

  • f is a pure function,
  • state subsumes all the variability resulting from the environment and the reactive system’s specifications so that f is pure.

f will be termed here as the reactive function. If we index time chronologically by a natural integer, so that the index n corresponds to the nth event occurring, the following holds:

  • (action_n, state_n+1) = f(state_n, event_n) where:
    • n is the nth event processed by the reactive system,
    • state_n is the state of the reactive system when the nth event is processed,
    • there is hence an implicit temporal relation here between the event occurrence and the state used to compute the reaction of the system.

Those observations give rise to implementation techniques relying on a reactive function f which explicitly computes for each event the new state of the reactive system, and the action to execute. Notable examples are:

  • Elm: The update :: Msg -> Model -> (Model, Cmd Msg) function corresponds closely to the reactive function f, Msg to events, Model to states, Cmd Msg to actions.
  • Pux (PureScript): foldp :: Event -> State -> EffModel State Event function is the equivalent formulation within the Pux framework. In Pux, EffModel State Eventis a record containing the new state value and an array of effects (actions) that may produce new events for the application to process.
  • Seed (Rust): The update function fn update(msg: Msg, model: &mut Model, _: &mut impl Orders<Msg>) matches the Elm update function (Cmd becomes Orders) while taking advantage of the mutability allowed in Rust.

Let’s now provide concrete examples. In pure functional languages, functional UI is a natural consequence of using the language. In other languages, such as JavaScript, a deliberate effort to adhere to the principles of functional UI will be required. In what follows, the article provides examples with the pure functional language Elm and vanilla JavaScript.

Examples

Elm

Let’s show an example of a simple Elm application that displays random cat gifs on the click of a button:

-- Press a button to send a GET request for random cat GIFs.
-- Read how it works: https://guide.elm-lang.org/effects/json.html

(some imports...)

-- MAIN
main =
  Browser.element
    { init = init
    , update = update
    , view = view
    }

-- MODEL
type Model
  = Failure
  | Loading
  | Success String

-- Initial state
init : () -> (Model, Cmd Msg)
init _ =
  (Loading, getRandomCatGif)

-- UPDATE
type Msg
  = MorePlease
  | GotGif (Result Http.Error String)

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    MorePlease ->
      (Loading, getRandomCatGif)

    GotGif result ->
      case result of
        Ok url ->
          (Success url, Cmd.none)

        Err _ ->
          (Failure, Cmd.none)

-- VIEW
view : Model -> Html Msg
view model =
  div []
    [ h2 [] [ text "Random Cats" ]
    , viewGif model
    ]

viewGif : Model -> Html Msg
viewGif model =
  case model of
    Failure ->
      div []
        [ text "I could not load a random cat for some reason. "
        , button [ onClick MorePlease ] [ text "Try Again!" ]
        ]

    Loading ->
      text "Loading..."

    Success url ->
      div []
        [ button [ onClick MorePlease, style "display" "block" ] [ text "More Please!" ]
        , img [ src url ] []
        ]

-- HTTP
getRandomCatGif : Cmd Msg
getRandomCatGif =
  Http.get
    { url = "https://api.giphy.com/v1/gifs/random?api_key=dc6zaTOxFJmzC&tag=cat"
    , expect = Http.expectJson GotGif gifDecoder
    }

gifDecoder : Decoder String
gifDecoder =
  field "data" (field "image_url" string)

From the code, it is possible to deduce that:

  • the application starts with some initial state and runs an initial command (init _ = (Loading, getRandomCatGif))
  • that initial state leads to displaying an initial view produced by the view function
  • clicking on a view button will send the MorePlease message to Elm’s runtime ([ button [ onClick MorePlease, ... ])
  • the update function update msg model = case msg of MorePlease -> (Loading, getRandomCatGif) will ensure that a MorePlease message will lead to fetching a random cat gif while updating the application’s state (model) to Loading (thus leading to the user interface displaying a loading message).
  • If the fetch is successful, it returns a url (GotGif Ok URL message), which leads to the user interface displaying the corresponding image (img [ src url ]

In addition to the update function, Elm also defines a runtime that receives events, passes them to the update function, and executes the computed commands. The developer thus only needs to worry about defining the shape of the application state and the update function. Answering the question what happens when events [X, Y, ...] occur is made easy by having a single, centralized update function, which computes the reactions to events.

Vanilla JavaScript

In the JavaScript world, the Hyperapp framework adopts an architecture strongly inspired by Elm’s with a few differences. Hyperapp is extremely light (2KB), with most of the code (80%) dedicated to handling its own virtual DOM implementation. Hyperapp however does not expose a pure reactive function. Instead, it uses a view function – like Elm does. Unlike Elm, the view function receives not just some state as its first parameter, but also as its second parameter an object containing all the actions that the application can execute.

The view function is thus not pure: it is what Jessica Kerr calls isolated. This means that the only dependencies of the function are its parameters. While pure functions are isolated, isolated functions are not necessarily pure as their parameters may be functions that produce effects, or variables that are controlled by the outside world. Isolated functions can still however be unit-tested by mocking their parameters if necessary. Hyperapp then falls short of following the functional UI principles, but still keeps some of the advantages of functional UI.

To understand how to architect a reasonably complex application with Hyperapp, the reader may refer to the Hyperapp-based implementation of a (Medium clone demo app) called Conduit. An Elm implementation of the same application is also available together with other implementations across a dozen other frameworks.

There is no need, however, to renounce any of the functional UI principles when implementing user interfaces with JavaScript. In a hypothetical implementation, the application shell would be in charge of wiring event sources to the update function, and similarly wiring the update function to a module executing the computed actions, replicating an event loop of sorts. The update function may (for instance) take the following shape, encoding its return value (of type Cmd Msg in Elm) with a single {command, params} object.

The reader is invited to consider the equivalent implementation in JavaScript of the previously discussed application displaying random cat gifs. The update function is as follows:

// Update function
function update(event, model) {
  // Event has shape `{[eventName]: eventData}`
  const eventName = Object.keys(event)[0];
  const eventData = event[eventName];

  if (eventName === MORE_PLEASE) {
    return {
      model: LOADING,
      commands: [
        { command: GET_RANDOM_CAT_GIF, params: void 0 },
        { command: RENDER, params: void 0 }
      ]
    };
  } else if (eventName === GOT_GIF) {
    if (eventData instanceof Error) {
      return {
        model: FAILURE,
        commands: [{ command: RENDER, params: void 0 }]
      };
    } else {
      const url = eventData;
      return {
        model: SUCCESS,
        commands: [{ command: RENDER, params: url }]
      };
    }
  }

  // Some unexpected event, graciously do nothing
  return {
    model: model,
    commands: []
  };
}

A basic event emitter is used to dispatch events. While the render function of any UI framework could be used, the render function in this simple demo is implemented with direct DOM cloning. Commands are thus executed as follows:

[MORE_PLEASE, GOT_GIF].forEach(event => {
  eventEmitter.on(event, eventData => {
    const { model: updatedModel, commands } = update(
      { [event]: eventData },
      model
    );
    model = updatedModel;

    if (commands) {
      commands.filter(Boolean).forEach(({ command, params }) => {
        if (command === GET_RANDOM_CAT_GIF) {
          getRandomCatGif()
            .then(response => {
              if (!response.ok) {
                console.warn(`Network request error`, response.status);
                throw new Error(response);
              } else return response.json();
            })
            .then(x => {
              if (x instanceof Error) {
                eventEmitter.emit(GOT_GIF, x);
              }
              if (x && x.data && x.data.image_url) {
                eventEmitter.emit(GOT_GIF, x.data.image_url);
              }
            })
            .catch(x => {
              eventEmitter.emit(GOT_GIF, x);
            });
        }
        if (command === RENDER) {
          if (model === LOADING) {
            setDOM(initViewEl.cloneNode(true), appEl);
          } else if (model === FAILURE) {
            setDOM(failureViewEl.cloneNode(true), appEl);
          } else if (model === SUCCESS) {
            const url = params;
            setDOM(successViewEl(url).cloneNode(true), appEl);
          }
        }
      });
    }
  });
});

Implementing functional UI by oneself is thus pretty simple. If you would rather reuse existing solutions, the raj or ferp projects are useful libraries that stick close to the functional UI principles. You need not fear breaking your application budget. The whole raj library is so small (33 lines of code) that it can be pasted entirely here:

exports.runtime = function (program) {
  var update = program.update
  var view = program.view
  var done = program.done
  var state
  var isRunning = true

  function dispatch (message) {
    if (isRunning) {
      change(update(message, state))
    }
  }

  function change (change) {
    state = change[0]
    var effect = change[1]
    if (effect) {
      effect(dispatch)
    }
    view(state, dispatch)
  }

  change(program.init)

  return function end () {
    if (isRunning) {
      isRunning = false
      if (done) {
        done(state)
      }
    }
  }
}

While Elm-like implementations are fundamentally simple, it may often provide a better understanding of an application behavior than a component-based implementation. Oftentimes, a component-based implementation quickly gives a good idea of what the user interface looks like, while the behavior of the interface (what happens when event X occurs) may have to be laboriously retrieved from the implementation details of the components. In other words, component-based implementations optimize for productivity through component reuse, while functional UI implementations optimize for correctness through matching use cases to the implementation.

Unit-testing user scenarios

A reactive system run leads to traces which are the sequence of (events, actions) that have occurred during the period of the run. To a correct behavior of a reactive system corresponds a set of admissible traces. Conversely, testing a reactive system consists of validating the actual traces vs. the set of admissible traces. This is facilitated by the existence of another pure function derived from our fundamental equation:

For all n: (action_n, state_n+1) = f(state_n, event_n)

The previously equation implies:

(action_0, state_1) = f(state_0, event_0)
(action_1, state_2) = f(state_1, event_1)
(action_2, state_3) = f(state_2, event_2)
...
(action_n, state_n+1) = f(state_n, event_n)

If we define h as the function which maps a sequence of events to the corresponding sequence of actions:

h([event_0]) = [action_0]
h([event_0, event_1]) = [action_0, action_1]
h([event_0, event_1, event_2]) = [action_0, action_1, action_2]
h([event_0, event_1, event_2, ..., event_n]) = [action_0, action_1, action_2, ..., action_n]

then h is a pure function! That means h can be easily tested, simply by feeding it inputs and checking that the expected outputs are produced. Notice how in h, there is no longer any mention of the state of the application. All of the above has the following consequences:

  • User scenarios can be tested in isolation, i.e user scenarios can be unit-tested, as user scenarios are sequences of events with their respective expected actions
  • Tests are conducted against the specified behavior of the application, not implementation details such as the shape of application state, or HTTP or sockets is used for data fetching
  • Unit-testing user scenarios allows developers to stay true to the test pyramid, and add a few, targeted integration and end-to-end tests to their panoply of unit tests
  • As such, developers suffer much less from long-running or flaky tests and are more productive (integration and end-to-end tests are expensive to write and hard to maintain)
  • Developers may pick any testing framework (or none)

When tests of user scenarios are fast to write and execute, it becomes feasible to imagine and test many more user scenarios in a given amount of time. Because user scenarios are simple sequences, it is easier to automate the generation of such sequences. In the case of a modelization of the user interface behavior with state machines, it is actually possible to automatically generate thousands of tests ensuring a higher coverage of user scenarios and edge cases than if manually and painfully writing tests.

The final result is that design and implementation bugs are detected earlier, leading to faster iterations and higher software quality. That is without a doubt the main selling point of functional UI techniques and the key reason why they are used in the development of safety-critical software.

Conclusion

User interfaces are reactive systems and as such can be specified by a pure reactive function mapping the events accepted by the user interface to actions on the interfaced system. Implementation techniques leveraging functional programming techniques can lead to an implementation closer to the specifications, easier to reason about, and test. Functional UI may be used to free developers from the tyranny of incompatible UI and testing frameworks and shift the focus to the specifications (the what) over the implementation (the how). To those who doubt that serious applications can be developed without a UI framework, the GitHub site does not rely on any UI framework.

With functional UI, which emphasizes isolated, single-concern actions, UI components, when used, most of the time only take care of the view concern – some frameworks call such components pure components. Additionally, the application shell calls the UI framework, instead of the UI framework calling user-provided framework-aware functions. In short, UI frameworks can still get used but revert to being simple libraries.

On the flip side, with functional UI, it is harder to reuse impure components, which diminishes the value of a framework component ecosystem. Additionally, functional UI requires a mental and methodological shift for front-end developers, which often give higher importance to rendering (get something on the screen) than to application behavior, and often favor productivity (of writing code) over correctness (which involves writing thorough tests).

However, Elm, in its seven years of life, has validated the functional UI approach and has shown that, with proper tooling, developers can quickly learn and enjoy the approach.

About the Author

Bruno Couriol holds a Msc in Telecommunications, a BsC in Mathematics and a MBA by INSEAD. Starting with Accenture, most of his career has been spent as a consultant, helping large companies addressing their critical strategical, organizational and technical issues. In the last few years, he developed a focus on the intersection of business, technology and entrepreneurship.

Rate this Article

Adoption
Style

Hello stranger!

You need to Register an InfoQ account or or login to post comments. But there's so much more behind being registered.

Get the most out of the InfoQ experience.

Allowed html: a,b,br,blockquote,i,li,pre,u,ul,p

Community comments

Allowed html: a,b,br,blockquote,i,li,pre,u,ul,p

Allowed html: a,b,br,blockquote,i,li,pre,u,ul,p

BT