Is Shared State Evil? A Case For Monolithic Front-ends

Shared state is considered harmful for a variety of reasons. In this article, I’ve chosen to catalogue them under two main categories:

1. Separation of concerns

If your functions, singletons and objects share state, they will become harder to test, debug and reason about. 

If a function depends only on some inputs and yields only some outputs, it will be very easy to unit test and debug, even for someone unfamiliar with your codebase.

However, if a function depends on some shared state, anyone working with that function needs to be aware of all other functions that modify or use said shared state.

2. Concurrency issues

Sharing mutable state between code being executed in parallel can lead to concurrency issues.

If shared state isn’t properly synchronized (via mutexes or atomic operations), it can end up in a corrupt state from concurrent writes or be read in a corrupt state, while a write is happening. Not to mention other issues such as deadlocks.

Even if shared state is properly synchronized, this can still lead to performance issues, especially on RISC architectures (e.g. ARM) where sequential memory ordering is more costly (or impossible to achieve) for many operations.

How does this translate into JavaScript?

The nice thing about JavaScript is that it’s single-threaded. Thus, shared state can never lead to memory corruption. There are web workers in most browsers and multi-process parallelism for Node, but those already have separate mechanisms for sharing state anyway.

Some concurrency issues can still arise due to the asynchronous nature of I/O operations, but this shouldn’t be an issue except in some rare cases, such as using the Streams API.

The only real issues we have left fall under separation of concerns. Whilst separation of concerns is normally a good goal, it can be quite hard to implement in frontend javascript.

All javascript functions inherently share a huge amount of data. But we usually choose to ignore this fact when writing our code. Apart from the completely avoidable global variables, there are three elephants in the room when it comes to shared state:

  • The DOM
  • The window
  • The server

Every single function that uses document or window is automatically reading and/or writing to some shared state. Even if two functions aren’t linked via a variable in their parent scope, they still share a state if both of them access the DOM or any of the properties of window, such as the current URL or the height or width of the page.

Furthermore, there is an even “trickier” place to spot hidden state in javascript: the server-side state.

We usually don’t think of the backend as being related to the frontend. So extending the concept of “state” to whatever is happening on the server-side may be a bit strange, but it’s a perfectly valid concept.

Imagine function A is responsible for updating data D on the server and function B has to get data D from the server. Even if A and B seem separated, they are linked via the server-side value of D.

Not using shared state is difficult

Since the ultimate purpose of JavaScript is usually to modify the DOM or upload some data to a server, it becomes pretty hard to separate your code without sharing some state.

There are various models that allow you to do separation of concerns, and most of them boil down to either:

  1. Parent-child models, where communication only happens between parent and kids. This relies on various message-passage systems whereby children functions/components can trigger a parent function/component and vice versa. That way, components can be written as pure function and state is only shared via well-defined channels.
  2. Centralised storage model, where communication happens with a centralised storage, which then communicates the relevant shared state changes to whatever components care about said shared state. This is quite similar to a), but without the hierarchical structure.

This can be well exemplified by the diagram below, which shows a React workflow with and without Redux (a centralized state library).

Whilst the above is React-specific, all popular front-end frameworks (e.g. Angular and Vue) boil down the handling of shared state to a combination of a) and b).

The problem with both methods is that you partially rely upon your own discipline to make sure state change happens in the “correct” way.

A single misuse of document or window or a simple request to the server that doesn’t “inform” the necessary components that the server’s state might change will be a design problem.

Even using most of these data messaging mechanisms ”correctly” can still result in a lot of overblown and slow code. Using a library like React or Angular “correctly” in the wrong way, can hurt performance and increase code size.

Most of the difficulty inherent in these frameworks is there to help us escape the evil of shared state. To remove haphazard use of event listeners, global variable, DOM data, and replace it with an easy-to-understand data flow. But is the added complexity worth it?

 

An alternative to popular data-flow models

One alternative to struggling with these state sharing mechanisms is to accept the fact that JavaScript is designed to work with loads of shared state.

To make your life easier, you could try a more monolithic design.

Build some functions that shamelessly use global variables and DOM data. Don’t think of them in a parent-child or MVC relation. Instead, avoid abusing global variables too much and rely on the inherent messaging and storage mechanisms exposed by web pages in order to make the code easy to reason about.

There are three important tricks to making a monolithic design work in JavaScript:

Use pure auxiliary functions

An important part of monolithic design is having as many auxiliary pure functions as possible. One thing people forget to do when working with modern frameworks is isolating code that doesn’t depend on any state, that’s not specific to a view, that doesn’t have any relation with any other function; a function that you can pass from codebase to codebase without having to integrate it in any way.

Here’s a simple example, a promise wrapper for setTimeout:

const timeout = (ms, promise) => {

return new Promise(function(resolve,reject) {

setTimeout(function() {

reject(new Error(“timeout”))

}, ms)

promise.then(resolve, reject)

});

Or something like a debounce function:

function debounce(func, wait, immediate) {

let timeout;

return function() {

let context = this;

let args = arguments;

let later = function() {

timeout = null;

if (!immediate) {

func.apply(context, args);

}

};

let call_now = immediate && !timeout;

clearTimeout(timeout);

timeout = setTimeout(later, wait);

if (call_now) {

func.apply(context, args);

}

};

}

Separate via view-neutral functions

Quite close to pure functions, from the perspective of how easy they are to reason about and use, we’ll find another category of functions: those that use global variables or the DOM, and work “as intended” no matter what the state of the global variables or the state of the DOM is.

I’ve used the term view-neutral to express the fact that these functions aren’t supposed to care about what the page looks like when they’re being called. They act the same in the login modal, the 404 page, the index page or the user profile view.

These functions might use shared state that is contained by a global variable or by document, window and even on the server. But whatever shared data they read or modify, they’re always there and it’s never in an incorrect state from the perspective of these functions.

Take, as an easy example, a function that checks if a login cookie for our page is present in our user’s sessions:

const is_loged_in = () => {

if (document.cookie.indexOf(‘login_token’) > -1) {

return true

}

return false;

}

This function can be considered, for all intents and purposes, risk-free and fully separated, as far as accessing the shared state goes.

This function accesses an object which is always present (a cookie) and it works as intended no matter what the cookie contains. Obviously, it makes no modifications to the document, so it doesn’t affect the way other functions work.

This next function is an example of where this line between a view-neutral function and a function that has to be aware of the global state gets a bit blurrier, though I would still call it a view-neutral function in many contexts. You can call it anywhere, anytime and it will behave the same way and not disrupt the behaviour of other functions.

It’s a wrapper over fetch, which displays a loading animation whilst the request is being answered and turns the answer’s body into an object, or, it returns null and displays an error to the user if anything went wrong.

const fetch_with_loading = async (request) => {

let obj = {}

// display a loading bar animation

start_loading();

try {

const resp = await timeout(15000, fetch(request, {credentials: “same-origin”}));

obj = await resp.json();

if (resp.status === 400) {

display_error(obj[‘error’])

obj = null;

}

} catch(e) {

display_error(`can’t reach server, check your internet connection.`);

obj = null;

}

// remove the loading bar animation

stop_loading();

return obj;

}

It’s a bit more delicate than our cookie-checking function. It mutates the DOM up to three times, by adding, modifying and removing elements.

However, if we were to look at the display_error and start/stop_loading functions we’d see that they aren’t really affected by the state of the current DOM – they do the same thing in all situations – though there is a chance they will interfere with previous loading bars or error messages, or end up cluttering the user’s screen with too many errors.

Furthermore, there is a call to fetch in there, which could essentially be altering shared state on the server. However, this function specifically doesn’t need to deal with the consequences of that call, since it’s just a “wrapper” over fetch.

The responsibility of informing every interested component that the server-side state has changed falls upon whatever composed the Request object and called fetch_with_loading.

Use the “default” state storage and messaging mechanisms

There are a lot of data-storage and data-change communication mechanisms inherent in the browser and with the communication protocols we use.

However, most people tend to ignore these mechanisms, or not exploit them to their fullest. This happens partly because some of their features interlap with the features of many frameworks, but also because using them would mean tangoing with that oh-so-evil use of global state.

To me, it always seemed a bit silly that tools like Redux and Flux exist, in light of the fact that the DOM has very similar features, only much faster and usually much more advanced.

So here are a few things you can use liberally in a monolithic design, in order to cope with the supposed “issues” of monolithic applications:

  1. Make full use of events, both by using the well-known event listeners, but also by creating your own event dispatchers.
  2. Abuse the local storage. Remember you can use the storage events, that will allow you to do a lot of cross-tab and cross-window wizardry.
  3. If possible, use of the Push API and WebSockets, allowing you to design your applications around a bi-directional communication with the backend.
  4. The DOM is an amazing source of data – use it. There’s no guarantee that there isn’t a difference between the variable foos_text and what the user sees inside the element foo. There is, however, a guarantee that document.getElementById(‘foo’).innerText is exactly what the user sees inside of foo. Once you start using shared state, you can start using the DOM more. This means that the source of data you use and the source of the data displayed to your user can often be the same. It might look like a minor thing, but having a single source of truth can save you a lot of debugging.
  5. Use Object.freeze to make objects immutable and const to make sure variable don’t get reassigned. This might seem like a minor thing, but as a codebase grows, const correctness goes a long way.

Making proper use of these mechanisms instead of just sharing global vars declared in the uppermost scope of your code, will go a long way. Proper monolithic design can be smaller, faster and just as safe and logical as using a more restricted data flow… it’s just harder to get right.

So is shared state evil?

Maybe it is, but trying to avoid shared state might be just as much of a burden in frontend JavaScript as working together with it.

You’ve got so much inherent shared state, in the form of the DOM and the server-side data, that you might as well make full use of it, instead of placing your code under horrible constraints.

JavaScript is not the worst language when it comes to shared state, partially due to the lack of multi-threading or due to the messaging-focused design of the DOM.

So going for a design that is more monolithic in nature might not be such a bad idea.

That’s not to say you shouldn’t split your code into components, but you shouldn’t be so pedantic about having strict state-sharing and state-modification hierarchies between them.

Instead, take a more liberal approach, use a limited amount of global variable and use the document and window objects to their full potential. Counteract the downsides of this using more pure functions and more view-neutral functions, as outlined above.

If you're looking for your next job as a software engineer, have companies apply to you by adding your profile to Snap.hr.