Build once, deploy many in React: Dynamic configuration properties

Build once, deploy many is an essential principle of software development. The main idea is to use the same bundle for all environments, from testing to production. This approach enables easy deployment and testability and is considered a fundamental principle of continuous delivery. It is also part of the twelve-factor methodology. As crucial as it is, it has not received significant support in the world of frontend development, and React is no exception.

In create-react-app, we can specify different configuration parameters using the REACT_APP.env properties—but those must be set at compile time. Create-react-app expects you to rebuild the application for each target environment, which violates the principle. And it seems that it is the most common approach nowadays. But what if you want, or need, to follow the build once, deploy many principle?

Why would you need that, you ask? The most common example is specifying the apiBaseUrl after compilation. You may say that a proper DevOps approach solves the problem. However, not all contexts are mature enough regarding DevOps. Moreover, rebuilding the sources per environment can be considered a waste of time and is a potential source of errors.

If you want to serve the application as a static bundle with configuration injected after the build, the following solutions may come in handy. I hereby present two possible solutions for dynamic configuration in React, each with its pros and cons. Both are type-safe, both guard programmers from unexpected errors, and neither needs any additional libraries.

But wait—there must already be an npm library for this, right? It is the React world after all. Frankly, I couldn’t find any. When I first stumbled upon this problem, I was amazed by the lack of options or existing solutions. I found one unanswered StackOverflow question (now hopefully answered by this post) and this library which does only half the job—it downloads the settings from a URL but leaves it up to the user to determine how to incorporate them into the application.

Finally, I found this article, which has been a great help. It inspired the solutions I am presenting.

So, let’s break it down.

Getting inspired by the ‘include JavaScript’ solution

The idea from the article above is to dynamically fetch a script stored in public/config.js that contains the config variables definition.

// public/config.js
// provided by the customer as a resource in the public folder, after compilation

const apiUrl = "http://api.myservice.com";
const env = "TST";

<!-- public/index.html -->

<script src="%PUBLIC_URL%/config.js"></script>
<script>
  window.config = { apiUrl, env };
</script>

 

It works and is very easy to implement, but there are two pitfalls.

  1. It is not type-safe (and cannot be, since browsers don’t support TypeScript), and TypeScript will complain about this.
  2. The config file is a JavaScript file, and it can contain arbitrary code that will get executed. In a well-maintained project, runnable code should never be part of the configuration. Also, this code is out of your control, which can cause problems.

If these pitfalls are OK by you, you don’t need to read further. But, if you don’t want your customers to inject arbitrary executable JavaScript and want to have config in a structured .json file without any side effects, these are the options.

The first solution uses a global config object. The second solution utilizes React context. The basic idea is the same for both: before rendering the application, fetch the config.json file and load the values from it. Only after the data has been fetched should you render the application and use the configuration values.

The first step is the same for both solutions: create config.json in the public folder and add it to .gitignore. This will serve as the injectable configuration.

// public/config.json
{
  "apiUrl": "http://api.myservice.com",
  "environment": "TST"
}

 

The global config object solution

If you don’t care about the steps leading to the solution, jump right to Full Codebase.

Start by adding a new file with the globally accessible configuration src/configuration/config.ts.

// config.ts
export interface DynamicConfig {
  apiUrl: string;
  environment: "DEV" | "TST" | "AKC" | "PROD";
}

export const defaultConfig: DynamicConfig = {
  apiUrl: "http://localhost:8080/undefinedApiUrl",
  environment: "DEV"
};

class GlobalConfig {
  config: DynamicConfig = defaultConfig;
}

export const globalConfig = new GlobalConfig();

export const globalConfigUrl = "config.json";

 

Now, in index.tsx, we fetch the config from URL, save it to the global config object, and only then do we even call React.render—this means that the first time any react-containing code is executed, our config has definitely been loaded. In case of error, we pass the error message to React.render.

// index.tsx:
import axios from "axios";
import React, {ReactElement} from "react";
import App from "./App";
import {globalConfig, globalConfigUrl} from "./configuration/config";

axios.get(globalConfigUrl)
  .then((response) => {
    globalConfig.config = response.data;
    return <App />;
  })
  .catch(e => {
      return <p style={{color: "red", textAlign: "center"}}>Error while fetching global config</p>;
  })
  .then((reactElement: ReactElement) => {
    ReactDOM.render(
      reactElement,
      document.getElementById("root")
    );
  });

 

Beware that the definition of globalConfig must be in a separate file; it must not be in index.tsx—otherwise, it will create a circular dependency, resulting in the following error: ReferenceError: can’t access lexical declaration ‘X’ before initialization.

Then, using the configuration in our service is as simple as this.

// BusinessService.ts
import { globalConfig } from "../configuration/config";

export class BusinessService {
  public static getSomeDataFromApi(): void {
    console.log("service method performing an action with the following config: ", globalConfig.config);
  }
}

 

There is one pitfall though—if you access the config from a static context, for example, from the root of a service file, you get the default config; see the code below. This is because the code in file root is executed before the fetch request has finished, that is, before the promise with the injected config is fulfilled.

// BusinessService.ts
import { globalConfig } from "../configuration/config";

const config = globalConfig.config; // does not work as desired
export class BusinessService {
  public static getSomeDataFromApi(): void {
    console.log("service method performing an action with the following config: ", config);
  }
}

 

For this reason, you should always access the config from method calls, never in the context of the file root. To prevent silent errors in these situations, we update the global config to use getter and setter with checks.

// config.ts
class GlobalConfig {
  config: DynamicConfig = defaultConfig; // assign a value because of TypeScript
  notDefinedYet = true;

  public get(): DynamicConfig {
    if (this.notDefinedYet) {
      throw new Error("Global config has not been defined yet.");
    } else {
      return this.config;
    }
  }

  public set(value: DynamicConfig): void {
    if (this.notDefinedYet) {
      this.config = value;
      this.notDefinedYet = false;
    } else {
      throw new Error("Global config has already been defined");
    }
  }
}

export const globalConfig = new GlobalConfig();

// BusinessService.ts
import { globalConfig } from "../configuration/config";

// const config = globalConfig.get(); // wrong way to obtain the config, throws error
export class BusinessService {
  public static getSomeDataFromApi(): void {
    // console.log("service method", config); // wrong way to obtain the config
    console.log(
      "service method performing an action with following config: ",
      globalConfig.get()
    ); // correct way to obtain the config
  }
}

 

If you want to further tweak the code, you can use JavaScript’s Object.defineProperty with custom getters and setters and thus have the neat property syntax globalConfig.config for calling the getter.

That’s it! Now you know how to fetch, store, and use the config. In another chapter below, I include a fully-functional example.

The React context solution

If you don’t care about the steps leading to the solution, jump right to Full Codebase.

Start by adding a new file with the dynamic configuration type definitions src/configuration/config.ts.

export interface DynamicConfig {
  apiUrl: string;
  environment: "DEV" | "TST" | "AKC" | "PROD";
}

export const defaultConfig: DynamicConfig = {
  apiUrl: "http://localhost:8080/undefinedApiUrl",
  environment: "DEV"
};

export const dynamicConfigUrl = "config.json";

 

In App.tsx, we fetch the configuration in useEffect before rendering the children of App. This is a rather standard approach for components that need to fetch data before rendering. We will need to store the current state of fetching and render based on it.

// App.tsx:
import axios from "axios";
import React, {useEffect, useState} from "react";
import {dynamicConfigUrl} from "./configuration/config";

const App: React.FC = () => {

  const [configLoadingState, setConfigLoadingState] = useState<"loading" | "ready" | "error">("loading");
  
  useEffect(() => {
    axios.get(dynamicConfigUrl)
      .then((response) => {
        // setConfig(response.data); // we will declare this method in another snippet
        setConfigLoadingState("ready");
      })
      .catch(e => {
          setConfigLoadingState("error");
      })
  }, []);
  
  if (configLoadingState === "loading") {
    return <p>loading...</p>
   }
  if (configLoadingState === "error") {
    return <p style={{color: "red", textAlign: "center"}}>Error while fetching global config </p>;
   }

  return /* whatever you return */;
};

export default App;

 

You could also move this code to its own component for better separation of concerns. We don’t show this here.

React context allows us to store the config and to access it anywhere in the React component tree. Its functional syntax can be a bit overwhelming if you haven’t encountered it before. If this is the case, I recommend checking its React documentation to better understand what is going on. We will need a provider to hold the value, a hook to consume the value, and also a way to set the value after fetching. We also must specify a default config value (for TypeScript), although it may never be used. The full code would look like this.

// configuration/useConfig.tsx:
import React, { useContext, useState } from "react";
import { defaultConfig, DynamicConfig } from "./config";

interface DynamicConfigContext {
  config: DynamicConfig;
  setConfig: (newConfig: DynamicConfig) => void;
}

const configContextObject = React.createContext<DynamicConfigContext>({
  config: defaultConfig,
  setConfig: () => {}
});

export const useConfig = () => useContext(configContextObject);

const ConfigContextProvider: React.FC = ({ children }) => {
  const [configState, setConfigState] = useState(defaultConfig);

  return (
    <configContextObject.Provider
      value={{
        config: configState,
        setConfig: setConfigState
      }}
    >
      {children}
    </configContextObject.Provider>
  );
};

export default ConfigContextProvider;

 

And then, we update App.tsx to use the context this way.

// App.tsx
import {useConfig} from "./configuration/useConfig";

// ... in the method: 
  const { setConfig } = useConfig();
  useEffect(() => {
    axios
      .get(dynamicConfigUrl)
      .then((response) => {
        setConfig(response.data);
        log.debug("Global config fetched: ", response.data);
        setConfigLoadingState("ready");
      })
      .catch((e) => {
        setConfigLoadingState("error");
      });
  }, [setConfig]);

 

To consume the value, we call the useConfig() hook. A downside of this approach is that we cannot delegate this logic to our service class. In React, hooks (including the useConfig() hook) may only be called from components. Since the service class is not a component, we cannot call the hook there. We can only call the hook in our component and need to pass the config value to the service. To effectively store an instance of the service, memorization can be used as follows.

// service/BusinessService.ts
import { DynamicConfig } from "../configuration/config";

export class BusinessService {
  constructor(readonly config: DynamicConfig) {}

  public getSomeDataFromApi(): void {
    console.log("service method", this.config);
  }
}

// arbitrary component:
import React, { useEffect, useMemo } from "react";
import { BusinessService } from "./BusinessService";
import { useConfig } from "../configuration/useConfig";

const BusinessComponent: React.FC = () => {
  const { config } = useConfig();
  const service = useMemo(() => new BusinessService(config), [config]);

  useEffect(() => {
    service.getSomeDataFromApi();
  }, [service]);

  return ( /* whatever */ );
};

export default BusinessComponent;

 

That’s it! Now you know how to fetch, store, and use the config. In the next chapter, I include a fully-functional example.

Full codebase

Both solutions do a great job, yet this approach still has one downside. Every new developer who checks out the project and starts it will get an error. This is because the config.json file is not committed and will be missing. While an error is precisely what we expect when the config.json file is missing in production, it is somewhat unfriendly to our developers. In the case of local development, a warning and usage of default config would be a better option. We can achieve this with the create-react-apps built-in static configuration. The value of NODE_ENV is automatically set according to whether the app is started with run, build, or test.

// ... promise handling
.catch((e) => {
  // In development, treat this case as a warning, render the app and use the default config values.
  // In production (and testing), on the other hand, show the error instead of rendering the app.
  if (process.env.NODE_ENV === "development") {
    log.warn(`Failed to load global configuration from '${dynamicConfigUrl}', using the default configuration instead:`, defaultConfig);
    setConfigLoadingState("ready"); // or globalConfig.set(defaultConfig)
    return <App />;
  } else {
    setConfigLoadingState("error"); // or return <p>error</p>;
  }
}

 

Now that’s really it! Here I include fully-functional examples of each of the solutions. I have also added logging, expressive error messages, and some other minor improvements.

Pros and cons

Let’s review the pros and cons of each approach.

The global config object

  • + If config is missing, no React code is ever called. If React code is called, we know for sure that the config has been provided.
  • + Configuration can be handled in services, and React does not need to know anything about dynamic configuration.
  • – The compiler does not check for static use of global config (code in the file’s root), it is only detected during runtime.
  • – Slightly complicated index.tsx.

The React context way

  • + It is a standard React pattern for doing these kinds of tasks.
  • + Every time we access the config, we are sure it has been loaded.
  • – Reading of config values must be handled by components and cannot be delegated to the services.
  • – Slightly complicated rendering tree (provider and fetcher needed).

Conclusion

If you need to follow the build once, deploy many principle in your React app, you now know two (plus one) different ways of doing that. The general idea of both of them is similar. Judging from the pros and cons, I really cannot say which one is better. That depends on your context and coding style, your code structure, and the way services are implemented. I have introduced the ideas behind each of the solutions and reviewed the pros and cons, thus you should have enough information to pick a solution that suits you. With the provided code examples, you should be able to incorporate the solution into your codebase. Happy coding!

Note:

In the examples, I use the axios and loglevel libraries. If you prefer a solution with zero additional libraries, just replace log. with console. and

axios.get(dynamicConfigUrl).then(/* whatever functionality */)

 

with

fetch(dynamicConfigUrl).then(response => response.json()).then(/* whatever functionality */);

 

That being said, logging directly to the console is bad practice and we don’t recommend doing it, except for local debugging.

 

Author: Antonín Teichmann

Consultant