DEV Community

Cover image for Unleashing the Power of Actors in Frontend Application Development
Ibrahim Ayuba
Ibrahim Ayuba

Posted on

Unleashing the Power of Actors in Frontend Application Development

In backend development, threads have traditionally been used to achieve concurrency, which aims to maximize computing power. However, issues arise from the way threads communicate via shared mutable state or memory. Problems like race conditions, memory visibility, and deadlock occur when synchronizing this shared state. Addressing these problems adds complexity and makes development challenging.

On the other hand, actors eschew shared memory entirely. Each actor in the system possesses mutable state but refrains from sharing it with other actors:

Actor programming retains mutable state but avoid sharing it.
Source: Seven Concurrency Models in Seven Weeks

Also, actors run concurrently with each other and communicate via messaging.

This innovative model is employed by languages, including Elixir to achieve fault tolerance and resilience in software.

In this article, we will explore how to harness the actor paradigm in frontend development to construct robust, scalable, and maintainable software.

Why Actors in the Frontend?

State management can be challenging, particularly in frontend applications. When developing a non-trivial application using React, the typical approach involves employing a state management library like Redux, Alt, or similar tools. These tools advocate for a "single source of truth," where all application state resides in a single object accessible to all components. This simplifies tasks such as hydration and debugging:

[...] This makes it easy to create universal apps, as the state from your server can be serialized and hydrated into the client with no extra coding effort. A single state tree also makes it easier to debug or inspect an application.
Source: Redux Style Guide

However, this approach often leads to unnecessary re-renders. Whenever the state changes, all components reliant on data from the central state re-render, resulting in performance issues. To mitigate this, techniques like memoization, caching, and selectors are commonly employed. While effective, these solutions introduce additional complexity to the codebase.

Actors offer an alternative by avoiding the concept of a "single source of truth." Instead, each actor maintains its own private local state:

An actor is an independent virtual processor with its own local (and private) state.
Source: The Pragmatic Programmer

This characteristic of the actor model can be leveraged in frontend applications.

Before delving into the implementation of actors in the frontend, let's explore the three fundamental characteristics of actors.

Send

In the actor model, communication between actors occurs through the exchange of messages. These messages are immutable and are sent asynchronously in a fire-and-forget manner. Additionally, actors handle messages they receive one at a time.

In essence, the actor model operates entirely through messaging:

[…] the most important aspect of actors is that you build applications by sending and receiving messages.
Source: Akka in Action

Create

Actors have the ability to create or spawn additional actors, thus establishing a system. This system resembles a tree structure, allowing any actor, irrespective of its location, to communicate with all other actors within the system.

Designing the Next Behavior

When an actor receives a message, it may change its behavior, which will be used for the next message it receives. For instance, imagine an actor that fetches data from an external API. It can exist in idle, loading, success, or error states.

So, if the actor is in the idle state and receives a message to load data, it will change its state to loading. When the next message arrives, the current state will be loading. Essentially, that's how actors change their behavior or state.

Finite state machines are useful for modeling actor behaviors.

Implementing Actors in Frontend Applications

XState is an excellent library that simplifies the utilization of actors in JavaScript applications. While this article focuses on using React, these principles apply equally well to other frameworks. In fact, they can be implemented anywhere JavaScript is executed.

Creating Actors

For actors to communicate with each other, they need to be part of the same system. The most effective approach is to establish one parent actor, which serves as the root actor. From this root actor, we can create a hierarchy of actors that can exchange messages with one another.

In this example, we'll utilize React's context API. However, there’s another way, which is highlighted in this article on the Stately blog.

const rootMachine = createMachine({
  entry: [
    spawnChild(cartActor, { systemId: "cart" }),
    spawnChild(favoritesActor, { systemId: "favorites" }),
  ],
});

export const rootContext = createActorContext(rootMachine);

const RootContext = ({ children }) => {
  return <rootContext.Provider>{children}</rootContext.Provider>;
};

export default RootContext;
Enter fullscreen mode Exit fullscreen mode

The createActorContext(rootMachine) function creates an actor, specifically the root actor, and makes it accessible throughout our application via React Context. More information about this function can be found in the XState docs.

The spawnChild() function is employed to create child actors. It requires the logic of our actor as the first argument and an object with a systemId as the second argument.

Afterward, we can use the assigned systemId, represented as a string, to send messages to the actor or to access its internal state from a component using useSelector() or from another actor using getSnapshot().

Actor Communication

As mentioned earlier, actors can exchange messages with other actors. In XState, this process is straightforward. To maintain consistency, every component containing logic should be linked to an actor. Moreover, components should exclusively communicate with their associated actors.

Using the actorRef, we can dispatch messages to the actor either from within our component or from another actor.

Suppose we aim to add a product to our cart whenever the addToCart button is clicked. This operation involves four steps:

First Step

When the user clicks on the button, we capture the click event and send a message to the btnActor using btnActorRef.send():

const AddToCartBtn = ({ product: { btnActorRef, ...product } }) => {

  const addToCart = (e) => {
    e.preventDefault();
    btnActorRef.send({ type: "ADD_PRODUCT_TO_CART", product });
  };
  return (
    <button
      className="btn bg-[#EF6024] border-[#EF6024] hover:scale-105 hover:bg-[#EF6024] hover:border-[#EF6024] w-full text-white"
      onClick={addToCart}
    >
      Add To Cart
    </button>
  );
};
Enter fullscreen mode Exit fullscreen mode

Second Step

The btnActor would receive the message, possibly execute some actions, and subsequently send another message to the cartActor using the sendTo() function.

The first argument the sendTo() receives is the actorRef, which we can obtain using system.get() along with the systemId we previously assigned. In this case, it would be cart:

import { createMachine sendTo, assign } from "xstate";

export const addToCartBtnActor = createMachine({
      on: {
        ADD_PRODUCT_COUNT: {
          target: "active",
          actions: sendTo(
        ({ system }) => system.get("cart"),
            ({ event }) => ({ type: "ADD_TO_CART", product: 
            event.product })),
        },
    }
});
Enter fullscreen mode Exit fullscreen mode

Third Step

The cartActor receives the message and updates its internal cart store accordingly, either by adding the product or by updating its count if the product already exists:

import { createMachine, assign } from "xstate";

export const cartActor = createMachine({
  context: {
    cart: [],
        cartCount: 0,
  },
  on: {
    ADD_TO_CART: {
      actions: [assign({
      cart: ({ context, event }) => {
        const isInCart = context.cart.find(
          (product) => product.id === event.product.id
        );
        if (isInCart) {
          return context.cart.map((product) => {
            if (product.id === isInCart.id)
              return { ...product, count: product.count + 1 };
            return product;
          });
        }
        return context.cart.concat({ ...event.product, count: 1});
      },
    }),
  assign({
     cartCount: ({ context }) => context.cart.map((item) => item.count).reduce((acc, cv) => acc + cv, 0),
    })],
 },
});
Enter fullscreen mode Exit fullscreen mode

Fourth Step

Finally, the cartActor updates its state accordingly, which will reflect in the cart component:

import { useSelector } from "@xstate/react";
import { rootContext } from "../../RootContext";
import { HiShoppingCart } from "react-icons/hi2";

const Cart = () => {
  const machineRef = rootContext.useActorRef().system.get("cart");
  const { cartCount } = useSelector(machineRef, (state) => state.context);
  return (
    <div className="indicator grid grid-cols-2">
      <span className="indicator-item badge badge-secondary bg-red-400">
        {cartCount}
      </span>
      <HiShoppingCart className="text-2xl" />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Designing the Next Behavior

Whenever an actor receives a message, it can decide to change its state and perform certain side effects or actions accordingly:

When an event instance is dispatched, the state machine responds by performing actions, such as changing a variable, performing I/O, invoking a function, generating another event instance, or changing to another state. Source: A crash course in UML state machines

For instance, when the user clicks on the like button of a product, the actor representing the favorites component might toggle the product's like state. If the product was initially liked, it would be unliked, and vice versa. Below is the logic for the favorites component actor:

import { createMachine } from "xstate";
const favoritesActor = createMachine({
  id: 'favorites',
  initial: 'unliked',
  states: {
    liked: {
      on: {
        TOGGLE: 'unliked'
      }
    },
    unliked: {
      on: {
        TOGGLE: 'liked'
      }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

This machine has two states, liked and unliked, and transitions between them occur when the TOGGLE event is triggered.

Conclusion

The actor paradigm presents a promising approach to developing robust and scalable frontend applications. By steering clear of a global shared state and promoting independent communication through messaging, actors can alleviate the challenges linked with state management. As the industry persists in adopting new paradigms, the actor model retains its value as a potent tool for attaining high-performance and dependable frontend solutions.

Top comments (1)

Collapse
 
sadertwe34 profile image
Kader Gavr • Edited

Explore how the actor model can revolutionize frontend development by enhancing state management and concurrency. Implementing actors reduces complexity and improves performance, aligning with trends from Actors News in software development.