All posts | Code

Code Complete: States Within Modules

Code Complete: States Within Modules main picture

You can write any code inside files (and outside definitions) in scripting languages like JavaScript: function definitions, function calls, and variable definitions and changes. This freedom simplifies development by enabling the creation of one-time scripts for simple or occasional tasks. On the other side, a careless development process leads to flaws that make the code and its maintenance significantly more difficult. They are so common in production code that we must discuss them separately.

These problems are not unique to JavaScript; they occur in many other interpreted languages, including Python, Ruby, and PHP.

You can read more on the difference between modules and scripts in our article. Here we’ll focus on flawed module design.

Assume we have an index.js module with the following content:

export const pi = '3.14';

Some other part of the program imports and uses it. The import of these modules typically takes place in various places across the program rather than in a single one.

// Somewhere in one place
import { pi } from '../index.js';

// Somewhere else
import { pi } from '../index.js';

The question is, how many times the contents of the index.js file are actually called? It is easy to check by printing something inside a module:

console.log('!!!');
export const pi = '3.14';

By running the program, you can see that the call occurred just once; the same with the constant. In this respect, export is very different from return inside functions. return is invoked on every call, and export is called only on the first import, and then it’s just reused.

This feature has a remarkable implication: the module can easily be turned into global state storage.

// state.js

export default {
  users: [],
};

Somewhere in other parts of the system:

// One file
import state from '../state.js';

// Some event handler
const addUser = (data) => {
  // here is logic
  const user = /* ... */;
  // and saving to state
  state.users.push(user);
};

// Another file
import state from '../state.js';

// Some event handler
const getUsers = () => {
  // If addUser was called somewhere before, then the data will be inside
  return state.users;
};

Even though these are separate files, the object imported from state.js is always the same.

What exactly happened here? Even though the code seems convenient to use, it’s based on practices that have always been considered bad. In fact, it creates a global variable that can be accessed and changed by any part of the system (via import). This is extremely dangerous and can result in serious errors, so global variables should be avoided whenever possible.

Access methods that are added to the state can aid in solving the issue in part:

// state.js

const state = {
  users: [],
};

export const addUser = (user) => state.users.push(user);
export const getUsers = () => state.users;

Thus, we will get not just global data but a global object (in the OOP sense, not a data type). This, however, makes little difference. Global data remains global.

Why is it so bad? Assume we've created an autocomplete library and connected it to the page. This library most likely stores data from the backend, for example, to speed up access. Then we have a task to connect two autocompletes on the same page, and this is where the surprises will begin. Changes made to one autocomplete will affect the other.

Theoretically, various autocompletes could use different data sources. However, the issue will persist when dynamically adding and removing autocompletes, for example. Thus, there is no way to design such an implementation that is error-free in any circumstance.

Here is another example. In tests, such mistakes immediately get to the surface. Tests should not be interdependent and entangled, which is impossible with a global state. Any changes to the state in one test will be mirrored in the other. Manually restoring them to their previous state is possible, but it is incredibly unreliable (it takes mastery to handle errors). It also adds a lot of unnecessary complexity.

Local state

Localizing the state within the application is the correct way to work with it. In the case of autocomplete, for example, it could be a function:

// Takes as input a selector of an autocomplete element
export default (selector) => {
  const state = {
    // The initial state of a particular autocomplete
  };

  const el = document.querySelector(selector);
  // Autocomplete logic
};

And usage:

const autocomplete1 = addAutocomplete('.input1');
const autocomplete2 = addAutocomplete('.input2');

Сode is structured in such a way to enable adding as many autocomplete as you want without worrying about them interfering with each other. Each autocomplete has its own local state to work with.

The same should be done in all other cases, whether we use frameworks or native JavaScript. The general principle of working with the state remains the same – the entire application is wrapped in a function that determines the global state for a specific application but is local to the application's run environment.

Valid global state

Are there any cases of a valid global state? At the very least, many libraries use it for configuration or internal needs. As previously stated, this can cause some problems, but it is not always significant:

// Localization lib in the app
import i18next from 'i18next';

// Сalling methods without using the result will cause mutations
i18next.init({
  lang: 'ru',
});


// Validation lib 
import * as yup from 'yup';

// Changing the yup state
yup.setLocale({
  mixed: {
    default: 'Não é válido',
  },
  number: {
    min: 'Deve ser maior que ${min}',
  },
});

Overall, never save data in a global state; only use objects that were created within the application.

User avatar Kirill Mokevnin
Kirill Mokevnin 14 July 2022
Suggested learning programs

From zero to a developer. Refunds in case you won't get a job

Frontend Developer icon
Profession
New
Development of front-end components for web applications
start anytime 10 months