March 2, 2023

Is global data bad?

Imagine you are building some widget that contains many nested components in it. At some point, you will need a way to control how this widget should look and work through some config object—especially if you want to publish it as a JavaScript library.

A config object is a perfect case for a global data. But we often hear that global data is bad.

Is it bad? If so, why and what can we do about it?

Why is it bad?

Global data is bad because it can be changed from any place in the project. When something can change in arbitrary, uncontrolled way, then things might break without you noticing.

Global data is harder to reason about: if multiple parts access the same global data (which is what global data is for), then you won’t easily know which part has changed it in a way that might break other parts.

The key reason here is mutability. Global data is bad if it’s mutable—which means it can be changed. If it’s immutable, then it’s not that bad because you will be sure that the data is the same everywhere; which means it’s easier to reason about.

But sometimes you need the global data to be mutable. In this case, the best thing you can do is to restrict access to it—in other words, to encapsulate it.

An example of unprotected global data

Let’s say you store your widget config data in appConfig.js.

export const appConfig = {
  maxNumberOfUploads: 5,
  supportedTypes: ['jpg', 'png'],
  isDarkMode: false
}

You can update this config directly from any place in your project.

import { appConfig } from './appConfig.js'

appConfig.isDarkMode = true

If you are pretty sure that it will be updated from a single place, then that’s fine. But the issue occurs when it can be changed from multiple places. Since appConfig is an object, then it means you can pass it anywhere you want, and it will modify the original object’s data; which will make it harder to debug the code and to know what part of your app changed a specific property.

Not only that, but currently there’s no way to ensure that the config fields are updated correctly—there’s no validation rules for that.

I can improve that by encapsulating the data by providing a getter and a setter for accessing the data with some rules.

Encapsulate mutable global data

The simplest way to restrict access to mutable data is to not export the object itself; instead, export a getter and a setter.

let appConfigData = {
  maxNumberOfUploads: 5,
  supportedTypes: ['jpg', 'png'],
  isDarkMode: false
}

export function appConfig() {
  return structuredClone(appConfigData)
}

export function setAppConfig(name, value) {
  if (!appConfigData.hasOwnProperty(name)) {
    throw new Error(`App config does not contain "${name}" option`)
  }
  if (!isValid(name, value)) {
    throw new Error(`Changing ${name} to ${value} is invalid`)
  }
  appConfigData[name] = value
}

function isValid(name, value) {
  // ...
}

When exporting the getter, it’s important to return a copy of that object so the user can’t change the original one. I used structuredClone to clone it—which is great for cloning an object with all of its nested fields.

The setter here takes the name of the config property that you want to change and the new value for it. Since all changes now are done through a function, then you can add any validations before updating the value. For this example, I added a check to make sure the user can’t add new properties. And below that, I added a validator for the new value—I added an unimplemented helper function called isValid, which I can implement based on what I want.

If the new config value passes all the checks, then I update the config with the new value—and this time I’m sure it will get a valid value.

Alternative approach: encapsulate the getter with a class

Another option you have for encapsulating mutable data is by wrapping it with a class. This way you’ll have more control on the internals of the object; you can choose what fields to provide a setter for—because you might want to disallow changes for some config fields.

Another benefit for this approach is that you can group the validation code with its field setter—which is much cleaner.

let appConfigData = {
  maxNumberOfUploads: 5,
  supportedTypes: ['jpg', 'png'],
  isDarkMode: false
}

class AppConfig {
  #maxNumberOfUploads
  #supportedTypes
  #isDarkMode

  constructor(data) {
    this.#maxNumberOfUploads = data.maxNumberOfUploads
    this.#supportedTypes = data.supportedTypes
    this.#isDarkMode = data.isDarkMode
  }

  get maxNumberOfUploads() {
    return this.#maxNumberOfUploads
  }

  set maxNumberOfUploads(value) {
    if (!Number.isNaN(value)) {
			throw new Error('maxNumberOfUploads must be a number')
    }

    this.#maxNumberOfUploads = value
  }

  get isDarkMode() { //... }
  set isDarkMode() { //... }

  get supportedTypes() { //... }
}

export function appConfig() {
  const clonedConfig = structuredClone(appConfigData)
  return new AppConfig(clonedConfig)
}

export function setAppConfig(newConfig) {
  appConfigData = newConfig
}

In this version, the getter is still cloning the config data, but it’s wrapping it with a new instance of AppConfig. I also changed the setter to take a new config object instead of name and value.

Updating the config would look like this:

import { appConfig, setAppConfig } from 'appConfig'

const config = appConfig()
config.isDarkMode = true

setAppConfig(config)

I prefer this approach because the code has a better structure. For example, I didn’t want to allow the user to change supportedTypes, so I just didn’t provide a setter for it. Also, look how easy it’s to validate each field—I just add the validation code in the field’s setter.

Conclusion

If you can implement your app without global data, then that’s great. You should try to avoid global data as much as you can—especially if they are mutable.

However, if they are needed, then restrict access to them by encapsulating them. When you encapsulate your global data, you can control what parts can change and how they change.

Stay up-to-date on the latest projects and articles from me