Sharing State In Distributed UIs At Runtime

April 05, 2022

TL;DR: Try not to share state between micro-apps in a micro-frontends architecture. If you have to share state, you can use this package @leanjs/runtime. The following article explains common pitfalls and trade-offs when sharing state in distributed UIs, and some alternatives to @leanjs/runtime that you can try.

The problem

Given two UI components, C1 and C2, displayed in the same app, C1 displays the username of a given user and C2 displays the username of the same user, is username part of the state of the UI?

Two UI components in the same app displaying the same username

It depends. If the UI is generated using some static site generator (SSG), then we could argue that the username is just a hardcoded value in an HTML page. In that case, the username would not be part of the state of the UI.

If the UI is generated dynamically on the browser, username is first null, then possibly a value after some request resolves, in which case username has more than one state. What is the difference between this username and the SSG one? The one generated dynamically changes over time, hence it’s part of the state of the UI. State is a snapshot of some data at a given point in time.

Let’s say that we implement our app using a micro-frontends architecture. Now the same C1 and C2 components are displayed dynamically on the browser in two separate micro-apps, M1 and M2. They still display the same username. Is username now part of the state? I’d say that at a micro level, username is part of the state. Micro level means when a given scope doesn’t surpasses the boundaries of a micro-app.

Two components displayed in two different micro-apps in a micro-frontends architecture

If there is a notion of micro level, there is a notion of macro level. Macro level means when a given scope in a micro-frontends architecture surpasses the boundaries of one or more micro-apps. Given the previous dynamically generated micro-apps, is username part of the state at a macro level?

It depends. If the username can’t ever be changed after a user is created, then username is not part of the state at a macro level. Both micro-apps share a data dependency in the browser at runtime. Nonetheless, the username in one micro-app can’t affect the other one. In this scenario, each micro-app can safely fetch the username independently and store it in two different local states.

OK, it may be inefficient to fetch the same username twice, but state and efficiency are two different things. For instance, we can solve the inefficiency problem by using HTTP caching if both username requests don’t happen simultaneously.

I’m going to make an important change in the previous scenario. Given the same micro-frontends architecture, is username part of the state at a macro level if the username can be changed by either micro-app?

If micro-app M2 changes the username and micro-app M1 displays the username, we would expect that when the username is changed on one side of the boundary, the other side is consistent and reflects that change. Which means, there is a notion of shared username state across the boundaries of the two micro-apps.

Solving the macro state problem

In the previous examples, there was a data dependency used in two different micro-apps, but only in the last example it was a shared state between the two. In LEAN micro-frontends we want to create strong boundaries to design loosely coupled micro-apps. How can we do that if they share state?

Client-side solution

Event-bus

We can use events to communicate state changes between micro-apps. For instance, each micro-app could connect to a shared event bus. We can implement an event bus for the browser using DOM events or creating our own EventEmitter. Either implementation, micro-apps are only listening for events while they are running - or we can also say while they are mounted.

When micro-app M2 changes the username it sends an event to the bus. If micro-app M1 is mounted then M1 will process the event and update its local state with the new username.This works perfectly if both micro-apps are running at the same time.

Client-side event bus to communicate state changes between micro-apps in a micro-frontends architecture

Let’s redesign our app so that micro-app M1 and micro-app M2 are displayed in two different pages. The username is displayed dynamically. When a user lands on the page that displays micro-app M1, M1 is mounted, it fetches the username, and it displays it.

The same user now navigates to the page that displays micro-app M2. Micro-app M1 is unmounted, and M2 is mounted. At that point, M2 changes the username and sends an event.

Event not delivered by an event bus

Micro-app M1 doesn’t process the event because M1 is not listening. A few seconds later the user navigates to the page that displays M1. What username is M1 displaying?

Micro-app displaying the wrong username

If M1 cached the username then M1 is possibly displaying the wrong username. An easy but nonoptimal solution is to refetch the username every time. Another option is to store all the messages in the event bus and keep track of which ones were delivered to which micro-app. This way when M1 is mounted it can process any events dispatched while it was not mounted. This adds complexity to our solution to solve a problem that we created. Does M1 care about events?

Let’s say that M2 changed the username 10 times while M1 was not mounted. Then M1 is mounted again, does M1 care about the 10 usernames or only the last one? M1 only cares about the last event. However, if M1 displayed a counter instead of username and each event from M2 was +1 then M1 would be interested in all the events dispatched since the last one it processed and not only the last event.

We are overcomplicating things. In reality, M1 only cares about the output of the last event. In other words, M1 doesn’t care about events, it cares about the current state.

Dispatch events, subscribe to state changes

Dispatching events and subscribing to state changes is what Redux and RxJS do. Both have streams of events, and subscribers can observe changes produced by those events.

In Redux, apps can dispatch actions (you can also think of them as events), and subscribers receive the output of those actions - also known as current state - after actions are processed by reducers in a centralised store.

Redux store being used to communicate state changes in micro-frontends

Using a dispatch-events-subscribe-to-state-changes approach to communicate macro state changes between micro-apps simplifies the architecture and the programming model.

Micro-app reading shared state from Redux in a micro-frontends architecture

There is a catch, micro-apps only need to share the output of those events/actions, in other words they only need to share (some) state. Anything else that is shared between micro-apps is unnecessary coupling e.g. reducers, middlewares.

Think of the following case where the previous M1 and M2 are displayed in two different pages. A user lands on the page that displays M1. The username shared state is null by default. M1 dispatches a fetch-username action. That action is processed by a Redux middleware (e.g. saga or thunk) which eventually updates the username shared state.

The user could also land first on the page that displays M2, in which case M2 would need to dispatch fetch-username as well. What if we later decide to display M1 and M2 in the same page? Are we fetching the username twice? If M1 and M2 are owned by two different teams, who should own the fetch username logic?

Consider a third team that owns a third micro-app which displays the current and previous username. The third team could decide to update the fetch-username logic in the shared store used by M1 and M2 to also satisfy the new requirement. Wait, this doesn’t look like a loosely coupled distributed architecture anymore. This looks like a distributed monolith 😬!

Let’s try RxJS now instead of Redux. In RxJS we use pure functions to change data based on streams of events, then we observe data changes. RxJS provides powerful ways to process and manipulate these streams. Example:

// @my-org/shared-state package
import { BehaviourSubject } from "rxjs"

export const username = new BehaviourSubject(null)

// ⚠️ I could apply any RxJS function to the shared state here
// e.g. username.pipe( someFunction )
// profile micro-app
import { username } from "@my-org/shared-state"

export const updateUsername = newUsername =>
  fetch(USERNAME_ENDPOINT, {
    method: "POST",
    body: JSON.strinify({ username: newUsername }),
  }).then(() => {
    username.next(newUsername)
    // TODO handle errors
  })

// ⚠️ I could apply any RxJS function to the shared state here,
// was the next function already run by other micro-apps?
// username.pipe( someFunction )
// chat micro-app
import { username } from "@my-org/shared-state"

// ⚠️ I could apply any RxJS function here to the shared state,
// was the next function already run by other micro-apps?
// username.pipe( someFunction )

username.subscribe(newUsername => {
  // TODO update my local username state with newUsername
})

// ⚠️ who is responsible for initialising username?
// ☑️ profile micro-app
// ☑️ chat micro-app
// ☑️ shared state package
// ☑️ other

Redux and RxJS share the same problem when it comes to sharing state in a distributed architecture, they are both too powerful. In loosely coupled micro-frontends we want behaviour to be confined within the boundaries of each micro-app. This way, ownership is clear.

I propose to split the idea of sharing state in two parts: 1) state itself, and 2) state logic, and to only share (some) state itself. Consider the previous Redux example where M1 and M2 need to fetch the username. We can move the logic to load that username to the micro-app. Example:

State logic moved from Redux to inside of the boundaries of a micro-app

Consider now the case where the same username needs to be displayed in two micro-apps M1 and M2 which are mounted in the same page. This time, the username fetching logic is distributed and self contained in each micro-app.

Logic to fetch the username is inside of the boundaries of two micro-apps that are mounted in the same page at the same time

We don’t want to run the same fetching logic twice, so instead of running it inside of each micro-app, each micro-app delegates running that logic to the “shared layer”. Because the “shared layer” is responsible for running state logic but not storing state logic, I’m going to call this layer shared runtime instead of shared store.

Two independent micro-apps delegate running state logic to the shared runtime

Server-side solution

We can try to make each micro-app independent from each other but aware of state changes by sending events from the server to the browser. We could send bidirectional events between server and browser using WebSockets.

When a micro-app changes some local state it should also “POST” a request to a backend to persist that change. Let’s say that a micro-app M2 changes some local state that also exists locally in micro-app M1. The backend could send a message to micro-app M1 to update its local state after micro-app M2 posted that change. Maybe you already realised it by now, this is another event bus.

Server-side event bus

Both client-side and server-side event buses share similar challenges, but the server-side one has a few more challenges because of its remote nature. The client-side implementation doesn’t require more resources when the number of micro-apps increases. In the case of the server-side implementation, if each micro-app is completely independent, it means that each micro-app would create its own connection to the server.

Creating a connection for each micro-app has possibly scalability and cost implications. We can minimise that by sharing one connection within the execution context. Notice, the more execution context we share, the higher coupling between micro-apps.

Hybrid solution

Imagine that we implement a chat app using a micro-frontends architecture consisting of 10 micro-apps, e.g. chat, profile, search, DM, etc. Each micro-app has the same list of users. Is the list of users a shared state at a macro level? As you may have guessed, the answer is, it depends.

Let’s say that a user can mute other users. If a user is muted in one micro-app, all the other micro-apps should hide any content generated by the muted user. Therefore, there is something about the users state that is shared across micro-apps.

If you have a convenient client-side solution to share state changes, you won’t probably consider sending an event from the server to the browser of user X when user X mutes another user. You already got that behaviour with the client-side solution.

However, let’s say that there is an admin user that can ban any user in the chat app. When a user is banned by an admin that user should be removed from any micro-app in every browser that is running our chat app. How do we implement that?

In this last scenario, the server could send an event to each browser that is running our chat app. Then all the micro-apps that observe that shared state could update. We can combine the server-side solution with the dispatch-events-subscribe-to-state-change solution to solve most use cases.

Hybrid solution of a server-side event bus and a client-side dispatch-events-subscribe-to-changes approach

Consider five micro-apps that are mounted at the same time. The five of them need to know what users are banned. We could create 5 different websocket connections so each micro-app can receive real-time updates from the server. We might pay for connections to our websocket provider, or the clients running our micro-apps could have limited resources. It’s probably more cost effective to share 1 websocket connection between each micro-app.

We can include a shared context in the shared runtime so that we can share not only some state and execution, but also some resources within the execution context.

Conclusion

Avoid sharing state between micro-apps. Make sure to differentiate macro state from micro state. If you find yourself sharing a lot of state at a macro level, it could be that the boundaries between micro-apps are not well designed. That could be an architecture smell indicating that your domains might be narrowed incorrectly, or even that engineering teams are not organised properly.

In a micro-frontends architecture, subscribe to state changes and dispatch client-side events to communicate these changes if you have to share state between micro-apps. Never share state logic between micro-apps to reduce coupling. We can combine it with events from/to the server to widen the use cases. This will increase coupling between micro-apps if we have to share some execution context for some reason, e.g. scalability.

I’ve built a shared runtime called @leanjs/runtime optimized for the hybrid solution described in this post. I’ve also built some wrappers to make it easier to use @leanjs/runtime within other libraries like React. You can see an example of it here.

Is there anything that you would do differently? Do you have any questions about this approach? We can discuss it using the links below. Looking forward to discussing it with you!


Profile picture

Written by Alex Lobera, software engineer passionate about OSS, mentoring, and all things Web.


Subscribe to my newsletter

I won’t send you spam. Unsubscribe at any time.