React: Build Once Run Anywhere

The full code can be found on GitHub.

Introduction

React supports the use of environmental variables. This is useful to configure an app differently depending on the environment it runs in. Like development, testing and production.

The problem is that the environmental variables must be set at build time. This is due to the fact that the app runs inside the browser. The environmental variables of the server (or Docker container) can’t be accessed from the browser.

Meaning that for each environment the environmental variables are defined when running yarn build/npm build.

This has two major downsides:

  1. The build time increases with each environment
    Depending on the project that might not only be the obvious ones like test and production but might also include dedicated builds i.e. for end-2-end tests.
  2. If a variable was set incorrectly a new build is required

To solve this the concept of “build once run anywhere” can be utilized.

The idea is simple: The app is configured at runtime using environmental variables.
For server side code this is simple since the app has access to the servers environmental variables. No matter if it is a Docker container or a dedicated server. For web apps this is harder to achieve since they run in the browser of the user and don’t know anything about the environment the web server (hosting the app) runs in.

The Solution

The solution presented here is based on this and this blog posts.

The idea is simple: The config is provided as a .json file on the web server (i.e. nginx). This config is created not during the build but during deployment. The content of the config is expanded with the values of the environmental variables where the web server is executed.

When the app starts the first thing it does is load the config file and make it available to the rest of the app as a context.

Setup

If you just want to play around with the project I suggest you start it using https://gitpod.io/. It is configured to install all required dependencies so that you can go right ahead.

If you want to follow along you’ll need the following:

Start by creating a new React project: npx create-react-app react-build-once-run-anywhere --template typescript.

Implementation

With the React project created we can start implementing the actual logic. The first thing we need is a template for the config. We’ll call it config.tmpl.json and place it in the root of our project.

This is where you define all the variables you need. It is expected that for each key the value is equal to the name of the key prefixed with a $.

Example of the config template:

{
  "ENVIRONMENT": "$ENVIRONMENT"
}

The next step is to expand the config template and place it into the public folder.

Create a script named expand-config.sh in the root of the project:

# Create a copy of the config template and rename it
cp config.tmpl.json config.json
# Read all environmental variables set in the current environment
export EXISTING_VARS=$(printenv | awk -F= '{print $1}' | sed 's/^/\$/g' | paste -sd,);
# Replace the keys in the config with the values from the environmental variables (if a match is found)
cat config.json | envsubst $EXISTING_VARS | tee config.json
# Move the populated config into the public folder
mv config.json public/config.json

To test it, define an environmental variable and then run the script above:

export ENVIRONMENT=development
./expand-config.sh

In the public folder you’ll now find a file named config.json:

{
  "ENVIRONMENT": "development"
}

As you see the script used the environmental variable to replace the key.

Now that we have the config ready we need to make use of it inside the app.

This will be done using Reacts context capabilities. Create a file called ConfigContext.tsx inside of the new folder context. The full implementation of it can be found here.

It contains two important things.

The first is the definition of the values we expect to be found inside of the config. This allows us to make use of TypeScripts auto completion.

/**
 * Defines the parameters available in the config
 */
export interface Config {
  ENVIRONMENT: string;
}

The second is the logic to actually load and initialize the config. (fetch(...) is used to avoid an external dependency.)

/**
 * Loads the config.json file and validates it.
 * The config.json file is expected to be available at the root url.
 *
 * @returns the config
 */
export const loadConfig = async () => {
  const response = await fetch("/config.json");

  const config: Config = await response.json();

  return config;
};

This context can then be used anywhere in the app to access the configuration. But first we must create it. This will be done inside the index.tsx file. The full code of it can be found here.

The existing code is extended to first load the config and depending on the result render the app or an error page.

// Load the config
loadConfig()
  // If the config was loaded successfully the app is rendered and the config context initialized
  .then((config) => {
    ReactDOM.render(
      <React.StrictMode>
        <ConfigContextProvider config={config}>
          <App />
        </ConfigContextProvider>
      </React.StrictMode>,
      document.getElementById("root")
    );
  })
  // If the config could not be loaded an error is shown
  .catch(() => {
    ReactDOM.render(
      <React.StrictMode>
        <p>Currently not available...</p>
      </React.StrictMode>,
      document.getElementById("root")
    );
  });

Build & Deploy

After creating and initializing the context the last step is to build and deploy the app. In this example I’ll use Docker and nginx to build and run the app.

The app will be build and put inside of Docker. Only when running the Docker image the config will be created and filled with the environmental variables.

The Dockerfile can be found here and consists out of two stages. One to build the app and the other to package the build artifacts into a deployable image.

The second step is important since it also contains the logic that will create the config file. This is done inside the last line of the Dockerfile. Instead of starting the nginx server directly a script is executed.

CMD ["/usr/bin/start-nginx.sh"]

This script is placed next to the Dockerfile. From there it is copied into the Docker image during the build process.

# Read all environmental variables set in the current environment
export EXISTING_VARS=$(printenv | awk -F= '{print $1}' | sed 's/^/\$/g' | paste -sd,);
# Replace the keys in the config with the values from the environmental variables (if a match is found)
cat /usr/share/nginx/html/config.json | envsubst $EXISTING_VARS | tee /usr/share/nginx/html/config.json

# Start nginx serving the app
nginx -g 'daemon off;'

It does two things:

  1. Replace the keys inside the config template with the values from the environmental variables
  2. Start the nginx server

Build the image by navigating into the root of the project and run docker build -f deploy/Dockerfile -t react-app ..

Once the build is finished the image can be run using docker run -d -p 9090:80 -e ENVIRONMENT=test react-app

You can now navigate to http://localhost:9090 and you will see “test” being rendered on the page. To experiment with it just stop the container and restart it with a different value for ENVIRONMENT.

That is it. The same build can now be deployed wherever you want and can be adjusted to the environment using environmental variables!

Disadvantages

Loading the app will take longer since the config has to be loaded. I found this to be negligible. But if you work in a context where every millisecond of load time is precious you might be better of configuring the app at build time.

Next steps

As mentioned in the beginning this post is based on this and this blog post. To get a better understanding I’d recommend to read them as well.