Customized Remote Work Solutions From the World’s Largest Fully Remote CompanyCustomized Remote Work SolutionsLearn More
Web front-end
13 minute read

Leveraging Declarative Programming to Create Maintainable Web Apps

Peter is a full-stack developer with 15 years of experience building apps for the web and desktop.

In this article, I show how adopting declarative-style programming techniques judiciously can allow teams to create web applications that are easier to extend and maintain.

“…declarative programming is a programming paradigm that expresses the logic of a computation without describing its control flow.” —Remo H. Jansen, Hands-on Functional Programming with TypeScript

Like most problems in software, deciding to make use of declarative programming techniques in your applications requires carefully evaluating the tradeoffs. Check out one of our previous articles for an in-depth discussion of these.

Here, the focus is on how declarative programming patterns can be gradually adopted for both new and existing applications written in JavaScript, a language that supports multiple paradigms.

First, we discuss how to use TypeScript on both the back and front end to make your code more expressive and resilient to change. We then explore finite-state machines (FSMs) to streamline front-end development and increase stakeholder involvement in the development process.

FSMs are not a new technology. They were discovered nearly 50 years ago and are popular in industries such as signal processing, aeronautics, and finance, where software correctness can be critical. They are also very well suited to modeling problems that frequently arise in modern web development, such as coordinating complex asynchronous state updates and animations.

This benefit arises due to constraints on the way state is managed. A state machine can be in only one state simultaneously and has limited neighboring states it can transition to in response to external events (such as mouse clicks or fetch responses). The result is usually a significantly reduced defect rate. However, FSM approaches can be difficult to scale up to work well in large applications. Recent extensions to FSMs called statecharts allow complex FSMs to be visualized and scale to much larger applications, which is the flavor of finite-state machines this article focuses on. For our demonstration, we will be using the XState library, which is one of the best solutions for FSMs and statecharts in JavaScript.

Declarative on the Back End with Node.js

Programming a web server back end using declarative approaches is a large topic and might typically start by evaluating a suitable server-side functional programming language. Instead, let’s assume you are reading this at a time when you’ve already chosen (or are considering) Node.js for your back end.

This section details an approach to modeling entities on the back end that has the following benefits:

  • Improved code readability
  • Safer refactoring
  • Potential for improved performance due to the guarantees type modeling provides

Behavior Guarantees Through Type Modeling

JavaScript

Consider the task of looking up a given user via their email address in JavaScript:

function validateEmail(email) {
  if (typeof email !== "string") return false;

  return isWellFormedEmailAddress(email);
}

function lookupUser(validatedEmail) {
  // Assume a valid email is passed in.
  // Safe to pass this down to the database for a user lookup..
}

This function accepts an email address as string and returns the corresponding user from the database when there is a match.

The assumption is that lookupUser() will only be called once basic validation has been performed. This is a key assumption. What if several weeks later, some refactoring is performed and this assumption no longer holds? Fingers crossed that the unit tests catch the bug, or we might be sending unfiltered text to the database!

TypeScript (first attempt)

Let’s consider a TypeScript equivalent of the validation function:

function validateEmail(email: string) {
  // No longer needed the type check (typeof email === "string").
  return isWellFormedEmailAddress(email);
}

This is a slight improvement, with the TypeScript compiler having saved us from adding an additional runtime validation step.

The safety guarantees that strong typing can bring haven’t really been taken advantage of yet. Let’s look into that.

TypeScript (second attempt)

Let’s improve type safety and disallow passing unprocessed strings as input to looukupUser:

type ValidEmail = { value: string };

function validateEmail(input: string): Email | null {
  if (!isWellFormedEmailAddress(input)) return null;

  return { value: email };
}

function lookupUser(email: ValidEmail): User {
  // No need to perform validation. Compiler has already ensured only valid emails have been passed in.

  return lookupUserInDatabase(email.value);
}

This is better, but it’s cumbersome. All uses of ValidEmail access the actual address through email.value. TypeScript employs structural typing rather than the nominal typing employed by languages such as Java and C#.

While powerful, this means any other type that adheres to this signature is deemed equivalent. For example, the following password type could be passed to lookupUser() without complaint from the compiler:

type ValidPassword = { value: string };

const password = { value: "password" };

lookupUser(password); // No error.

TypeScript (third attempt)

We can achieve nominal typing in TypeScript using intersection:

type ValidEmail = string & { _: "ValidEmail" };

function validateEmail(input: string): ValidEmail {
  // Perform email validation checks..

  return input as ValidEmail;
}

type ValidPassword = string & { _: "ValidPassword" };
function validatePassword(input: string): ValidPassword { ... }

lookupUser("[email protected]"); // Error: expected type ValidEmail.
lookupUser(validatePassword("MyPassword"); // Error: expected type ValidEmail.
lookupUser(validateEmail("[email protected]")); // Ok.

We’ve now achieved the goal that only validated email strings can be passed to lookupUser().

Pro Tip: Apply this pattern easily using the following helper type:

type Opaque<K, T> = T & { __TYPE__: K };

type Email = Opaque<"Email", string>;
type Password = Opaque<"Password", string>;
type UserId = Opaque<"UserId", number>;

Pros

By strongly typing entities in your domain, we can:

  1. Reduce the number of checks needing to be performed at runtime, which consume precious server CPU cycles (although a very small amount, these do add up when serving thousands of requests per minute).
  2. Maintain fewer basic tests due to the guarantees the TypeScript compiler provides.
  3. Take advantage of editor- and compiler-assisted refactoring.
  4. Improve code readability through improved signal-to-noise ratio.

Cons

Type modeling comes with some tradeoffs to consider:

  1. Introducing TypeScript usually complicates the toolchain, leading to longer build and test suite execution times.
  2. If your goal is to prototype a feature and get it into users’ hands ASAP, the extra effort required to explicitly model the types and propagate them through the codebase may not be worth it.

We’ve shown how existing JavaScript code on the server or shared back-end/front-end validation layer can be extended with types to improve code readability and allow for safer refactoring—important requirements for teams.

Declarative User Interfaces

User interfaces developed using declarative programming techniques focus effort on describing the “what” over the “how.” Two of the main three basic ingredients of the web, CSS and HTML, are declarative programming languages that have stood the test of time and more than 1 billion websites.

The main languages powering the web
The main languages powering the web.

React was open-sourced by Facebook in 2013, and it significantly altered the course of front-end development. When I first used it, I loved how I could declare the GUI as a function of the application’s state. I was now able to compose large and complex UIs from smaller building blocks without dealing with the messy details of DOM manipulation and tracking which parts of the app need updating in response to user actions. I could largely ignore the time aspect when defining the UI and focus on ensuring my application transitions correctly from one state to the next.

Evolution of front-end JavaScript from how to what
Evolution of front-end JavaScript from how to what.

To achieve a simpler way to develop UIs, React inserted an abstraction layer between the developer and the machine/browser: the virtual DOM.

Other modern web UI frameworks have also bridged this gap, albeit in different ways. For example, Vue employs functional reactivity through either JavaScript getters/setters (Vue 2) or proxies (Vue 3). Svelte brings reactivity through an extra source code compilation step (Svelte).

These examples seem to demonstrate a great desire in our industry to provide better, simpler tools for developers to express application behavior through declarative approaches.

Declarative Application State and Logic

While the presentation layer continues to revolve around some form of HTML (e.g., JSX in React, HTML-based templates found in Vue, Angular, and Svelte), I postulate that the problem of how to model an application’s state in a way that is easily understandable to other developers and maintainable as the application grows is still unsolved. We see evidence of this through a proliferation of state management libraries and approaches that continues to this day.

The situation is complicated by the increasing expectations of modern web apps. Some emerging challenges that modern state management approaches must support:

  • Offline-first applications using advanced subscription and caching techniques
  • Concise code and code reuse for ever-shrinking bundle size requirements
  • Demand for increasingly sophisticated user experiences through high-fidelity animations and real-time updates

(Re)emergence of Finite-state Machines and Statecharts

Finite-state machines have been used extensively for software development in certain industries where application robustness is critical such as aviation and finance. It’s also steadily gaining in popularity for front-end development of web apps through, for example, the excellent XState library.

Wikipedia defines a finite-state machine as:

An abstract machine that can be in exactly one of a finite number of states at any given time. The FSM can change from one state to another in response to some external inputs; the change from one state to another is called a transition. An FSM is defined by a list of its states, its initial state, and the conditions for each transition.

And further:

A state is a description of the status of a system that is waiting to execute a transition.

FSMs in their basic form do not scale well to large systems due to the state explosion problem. Recently, UML statecharts were created to extend FSMs with hierarchy and concurrency, which are enablers for wide use of FSMs in commercial applications.

Declare Your Application Logic

First, what does an FSM look like as code? There are several ways to go about implementing a finite-state machine in JavaScript.

  • Finite-state machine as a switch statement

Here’s a machine describing the possible states that a JavaScript can be in, implemented using a switch statement:

const initialState = {
  type: 'idle',
  error: undefined,
  result: undefined
};

function transition(state = initialState, action) {
  switch (action) {
    case 'invoke':
      return { type: 'pending' };
    case 'resolve':
      return { type: 'completed', result: action.value };
    case 'error':
      return { type: 'completed', error: action.error ;
    default:
      return state;
  }
}

This style of code will be familiar to developers who’ve used the popular Redux state management library.

  • Finite-state machine as a JavaScript object

Here’s the same machine implemented as a JavaScript object using the JavaScript XState library:

const promiseMachine = Machine({
  id: "promise",
  initial: "idle",
  context: {
    result: undefined,
    error: undefined,
  },
  states: {
    idle: {
      on: {
        INVOKE: "pending",
      },
    },
    pending: {
      on: {
        RESOLVE: "success",
        REJECT: "failure",
      },
    },
    success: {
      type: "final",
      actions: assign({
        result: (context, event) => event.data,
      }),
    },
    failure: {
      type: "final",
      actions: assign({
        error: (context, event) => event.data,
      }),
    },
  },
});

While the XState version is less compact, the object representation has several advantages:

  1. The state machine itself is simple JSON, which can be easily persisted.
  2. Because it is declarative, the machine can be visualized.
  3. If using TypeScript, the compiler checks that only valid state transitions are performed.

XState supports statecharts and implements the SCXML specification, which makes it suitable for use in very large applications.

Statecharts visualization of a promise:

Finite-state machine of a promise
Finite-state machine of a promise.

XState Best Practices

The following are some best practices to apply when using XState to help keep projects maintainable.

Separate Side Effects from Logic

XState allows side effects (which include activities such as logging or API requests) to be independently specified from the logic of the state machine.

This has the following benefits:

  1. Assist detection of logic errors by keeping the state machine code as clean and simple as possible.
  2. Easily visualize the state machine without needing to remove extra boilerplate first.
  3. Easier testing of the state machine by injecting mock services.
const fetchUsersMachine = Machine({
  id: "fetchUsers",
  initial: "idle",
  context: {
    users: undefined,
    error: undefined,
    nextPage: 0,
  },
  states: {
    idle: {
      on: {
        FETCH: "fetching",
      },
    },
    fetching: {
      invoke: {
        src: (context) =>
          fetch(`url/to/users?page=${context.nextPage}`).then((response) =>
            response.json()
          ),
        onDone: {
          target: "success",
          actions: assign({
            users: (context, event) => [...context.users, ...event.data], // Data holds the newly fetched users
            nextPage: (context) => context.nextPage + 1,
          }),
        },
        onError: {
          target: "failure",
          error: (_, event) => event.data, // Data holds the error
        },
      },
    },

    // success state..

    // failure state..
  },
});

While it’s tempting to write state machines in this way while you’re still getting things working, a better separation of concerns is achieved by passing side effects as options:

const services = {
  getUsers: (context) => fetch(
    `url/to/users?page=${context.nextPage}`
  ).then((response) => response.json())
}

const fetchUsersMachine = Machine({
  ...
  states: {
    ...
    fetching: {
      invoke: {
        // Invoke the side effect at key: 'getUsers' in the supplied services object.
        src: 'getUsers',
      }
      on: {
        RESOLVE: "success",
        REJECT: "failure",
      },
    },
    ...
  },
  // Supply the side effects to be executed on state transitions.
  { services }
});

This also allows for easy unit testing of the state machine, allowing explicit mocking of user fetches:

async function testFetchUsers() {
  return [{ name: "Peter", location: "New Zealand" }];
}

const machine = fetchUsersMachine.withConfig({
  services: {
    getUsers: (context) => testFetchUsers(),
  },
});

Splitting Up Large Machines

It’s not always immediately obvious how best to structure a problem domain into a good finite-state machine hierarchy when starting out.

Tip: Use the hierarchy of your UI components to help guide this process. See the next section on how to map state machines to UI components.

A major benefit of using state machines is to explicitly model all the states and transitions between states in your applications so that the resulting behavior is clearly understood, making logic errors or gaps easy to spot.

In order for this to work well, machines need to be kept small and concise. Fortunately, composing state machines hierarchically is easy. In the canonical statecharts example of a traffic light system, the “red” state itself becomes a child state machine. The parent “light” machine isn’t aware of the internal states of “red” but decides when to enter “red” and what the intended behavior is upon exiting:

Traffic light example using statecharts
Traffic light example using statecharts.

1-1 Mapping of State Machines to Stateful UI Components

Take, for example, a much simplified, fictional eCommerce site that has the following React views:

<App>
  <SigninForm />
  <RegistrationForm />
  <Products />
  <Cart />
  <Admin>
    <Users />
    <Products />
  </Admin>
</App>

The process for generating state machines corresponding to the above views may be familiar for those who have used the Redux state management library:

  1. Does the component have state that needs to be modeled? For example, Admin/Products may not; paged fetches to the server plus a caching solution (such as SWR) may suffice. On the other hand, components such as SignInForm or the Cart usually contain state that needs to be managed, such as data entered into fields or the current cart contents.
  2. Are local state techniques (e.g., React’s setState() / useState()) sufficient to capture the problem? Tracking whether the cart popup modal is currently open hardly requires the use of a finite-state machine.
  3. Is the resulting state machine likely to be too complex? If so, split the machine into several smaller ones, identifying opportunities to create child machines that can be reused elsewhere. For example, the SignInForm and RegistrationForm machines may invoke instances of a child textFieldMachine to model validation and state for user email, name, and password fields.

When to Use a Finite-state Machine Model

While statecharts and FSMs can elegantly solve some challenging problems, deciding the best tools and approaches to use for a particular application usually depends on several factors.

Some situations where using finite-state machines shine:

  • Your application includes a considerable data entry component where field accessibility or visibility is governed by complex rules: for example, form entry in an insurance claims app. Here, FSMs help ensure business rules are implemented robustly. Further, the visualization features of statecharts can be used to help increase collaboration with non-technical stakeholders and identify detailed business requirements early on in development.
  • To work better on slower connections and deliver higher-fidelity experiences to users, web apps must manage increasingly complex async data flows. FSMs explicitly model all states an application can be in, and statecharts can be visualized to help diagnose and solve asynchronous data problems.
  • Applications that require a lot of sophisticated, state-based animation. For complex animations, techniques for modeling animations as event streams through time with RxJS are popular. For many scenarios, this works well, however, when rich animation is combined with a complex series of known states, FSMs provide well-defined “rest points” that the animations flow between. FSMs combined with RxJS seem the perfect combination to help deliver the next wave of high-fidelity, expressive user experiences.
  • Rich client applications such as photo or video editing, diagram-creation tools, or games where much of the business logic resides client side. FSMs are inherently decoupled from the UI framework or libraries and are easy to write tests for allowing high-quality applications to be iterated on quickly and shipped with confidence.

Finite-state Machine Caveats

  • The general approach, best practices, and API for statechart libraries such as XState are novel to most front-end developers, who will require investment of time and resources in order to become productive, particularly for less experienced teams.
  • Similar to the previous caveat, while XState’s popularity continues to grow and is well documented, existing state management libraries such as Redux, MobX, or React Context have huge followings that provide a wealth of online information XState doesn’t yet match.
  • For applications following a simpler CRUD model, existing state management techniques combined with a good resource caching library such as SWR or React Query will suffice. Here, the extra constraints FSMs provide, while incredibly helpful in complex apps, may slow development down.
  • The tooling is less mature than other state management libraries, with work still underway on improved TypeScript support and browser devtools extensions.

Wrapping Up

Declarative programming’s popularity and adoption in the web development community continue to rise.

While modern web development continues to become more complex, libraries and frameworks that adopt declarative programming approaches surface with increasing frequency. The reason seems clear—simpler, more descriptive approaches to writing software need to be created.

Using strongly typed languages such as TypeScript allows entities in the application domain to be modeled succinctly and explicitly, which reduces the chance of errors and amount of error-prone checking code that needs to be manipulated. Adopting finite-state machines and statecharts on the front end allows developers to declare an application’s business logic through state transitions, enabling the development of rich visualization tools and increasing the opportunity for close collaboration with non-developers.

When we do this, we shift our focus from the nuts and bolts of how the application works to a higher-level view that allows us to focus even more on the needs of the customer and create lasting value.

Understanding the basics

What is the difference between declarative and imperative programming?

Imperative programming tells the computer how to achieve a result through a series of commands like in a recipe, whereas declarative programming describes the desired end result to be achieved.

How does a finite-state machine work?

A finite-state machine begins in its starting state and transitions to another known state in response to an action such as a button press or response from the server.

What do you mean by finite-state machines?

Finite-state machines impose restrictions on the way a program can be written, which reduces potential errors because they can be in only one state at any time and have a finite number of states they can move between.

What is the link between type modeling and declarative programming?

Types allow developers to describe the application’s entities, which avoids writing extra code to verify they are being used correctly throughout the application.

What is a statechart?

A statechart is a recent extension of the finite-state machine that solves some of its problems, such as exploding state by introducing hierarchical machines, and allows visualizing the behavior of the system.