The obscure injection of values into built React apps
- 10 minsIndex
Introduction
Using React to develop web applications is great. Both the framework simplicity and the wide ecosystem of packages makes React the dominant player when it comes to creating Single Page Applications (SPAs) ⚛️.
With the recent popularity of DevOps tools, and the ease for automatized deployments, the injection of runtime-specified application-level values has become crucial, whether these values determine logging level of the app, or the URL where a certain API is hosted. On this context, the absence of a mechanism to inject values to built React applications at runtime, has become a problem.
This post covers the common misunderstandings around this topic, and presents a solution.
Disclaimer: I am not a React expert, so there may be some space for improvement here and there.
Context
In order to understand the specific scenario where the React limitation is located, some context is required. In case you already know what a built React application is, and what point in time the word runtime refers to, please, jump into the proposed solution section.
What is a built React app? 📦
Simply put, built React application is the result of the use of Webpack to bundle together all the different assets that a React project may defined (.js
, .css
, sass
, .png
files…), to generate a small group of compressed equivalents (usually called bundles). The distribution of these bundles increase the performance of the web applications, as their size is smaller than the original assets.
All React applications bootstrapped using the create-react-app tool implicitly include Webpack as the bundling engine every time the developers use react-scripts build
(or its shortcut npm build
).
What is runtime referring to? ⚙️
The word runtime makes reference to the point in time, within the lifecycle of the application, where the application is just about to be executed. This is different from the commonly known build-time, which makes reference to where the application is built and optimized (using Webpack, encapsulating the application on a Docker image, or both).
As an example, runtime refers to when a developer runs any of these start commands:
# Launch the app before being built (development mode)
$ npm start
# Launch the app after being built (production mode)
$ serve -s 'build'
# Launch the app from within a Docker image
$ docker run <IMAGE_NAME>:<IMAGE_VERSION>
Where is the limitation located?
As stated before, React does not have a mechanism to inject specific values into a built application at runtime. At that point in time, the bundles would have been already generated, and any change to their contents would require the developer to re-build the application.
It does have, however, a way of injecting values when an application has not been built yet, by the use of the REACT_APP_<VARIABLE_NAME>
environment variables, which are available though the use of the process.env.REACT_APP_<VARIABLE_NAME>
within the application JavaScript. This mechanism is documented in the Add custom environment variable section of the React documentation
Common misconceptions
Throughout the internet there are a bunch of blog articles commenting, very briefly, how the usage of REACT_APP_<VARIABLE_NAME>
environment variables can indeed inject data into React applications.
What they fail to clarify is that, this approach is only valid when you launch the application in development mode, and therefore, before building any kind of optimized bundle out of it.
Some misleading articles examples are:
- Adding env vars to your app: which does not cover the injection on built apps.
- Using env vars in React: which does not cover the injection on runtime, only built-time.
Quoting the official React documentation:
The environment variables are embedded during the build time. Since Create React App produces a static HTML/CSS/JS bundle, it can’t possibly read them at runtime
Proposed solution
The proposed solution has been inspired from this fantastic article by Krunoslav Banovac. The article explains the injection of environment variables into a built React application, through the use of a light-weigh shell script executed just before running the main application. The script makes the injected values available through the use of the window.env
JavaScript object.
The article also contains some non-related Nginx configuration steps. Those will be avoided during this explanation.
Step 1: Creation of an .env file
The first step is to create an .env
file at the root of the project. The name of the file is just a convention, any name would do. This file will be in charge of storing the list of environment variables that will be parsed and injected.
An example may be:
API_SERVER_HOST=http://0.0.0.0
API_SERVER_PORT=8080
Step 2: Creation of the parsing script
Then, an environment-parsing script is necessary to harvest any env. variable available at runtime. This script will search for those env. variables defined in the .env
file, overriding their default values with the ones the developer may have manually defined.
This is the proposed script:
#!/bin/sh
### NOTE:
###
### Script that parses every variable defined in the source file
### in order to replace the default values with environment values,
### and dump them into a JavaScript file that gets loaded by the app.
###
### Arguments:
### --destination: Destination folder within the project root (i.e. public).
### --output_file: Name of the run-time generated output JavaScript file.
### --source_file: Name of the source file to read variables names from.
# Define default values
destination="build"
output_file="environment.js"
source_file=".env"
# Argument parsing
while [ "$#" -gt 0 ]; do
case $1 in
-d|--destination) destination=${2}; shift ;;
-o|--output_file) output_file=${2}; shift ;;
-s|--source_file) source_file=${2}; shift ;;
*) echo "Unknown parameter passed: $1"; exit 1 ;;
esac
shift
done
PROJECT_DIR="$(dirname "$0")/.."
# Define file paths
OUTPUT_PATH="${PROJECT_DIR}/${destination}/${output_file}"
SOURCE_PATH="${PROJECT_DIR}/${source_file}"
# Define AWK expressions to parse file and get env. vars
AWK_PAD_EXP="\" \""
AWK_KEY_EXP="\$1"
AWK_VAL_EXP="(ENVIRON[\$1] ? ENVIRON[\$1] : \$2)"
AWK_ALL_EXP="{ print ${AWK_PAD_EXP} ${AWK_KEY_EXP} \": '\" ${AWK_VAL_EXP} \"',\" }"
# Build the run-time generated JavaScript environment file
echo "window.env = {" > "${OUTPUT_PATH}"
awk -F "=" "${AWK_ALL_EXP}" "${SOURCE_PATH}" >> "${OUTPUT_PATH}"
echo "}" >> "${OUTPUT_PATH}"
As you can see, the script can be parametrized:
--destination
: folder to place the output file. By defaultbuild
.--output
: name for the resulting output file. By defaultenvironment.js
.--source_env
: file defining the env. variable defaults. By default.env
.
Step 3: Add import to index.html
Finally, we need to import the parsing output file into our application. This will require to modify the public/index.html
file, the one serving as canvas to which the React components are attached.
Modify index.html
to add the following line:
<head>
...
<!--
The file 'environment.js' provides run-time values being propagated by the user.
It only exists at run-time, when 'parse-env.sh' is called before starting the server.
-->
<script type="text/javascript" src="%PUBLIC_URL%/environment.js"></script>
...
</head>
Optional changes
Even though at this point we have done all the necessary changes to inject environment values into a built React application at runtime, there are a couple of optional changes to make this approach even more convenient.
Up to now, this mechanism is applied when we manually run the parse-env.sh
shell script before launching the application. For example:
./scripts/parse-env.sh && serve -s 'build'
However, what happens with the development mode? Could we automatically run that script when we are testing a pre-built application, so that we have a consistent value-injecting mechanism to both development (pre-built) and production (built) scenarios?
Yes, we can. Let me introduce you to the prestart
rule within the scripts
section of the package.json
file. That rule is used to specify a given set of commands that will be run before the classic npm start
. In this case, just modify the file and add:
{
"scripts": {
"prestart": "./scripts/parse-env.sh --destination public"
}
}
Note: notice how, in this case, the destination has been set to public
, which is where development mode run applications put their index.html
canvas file.
Last but not least, add the parsing script output file to the .gitignore
list. This is not a file worth committing, as it will be generated on-the-fly, everytime we launch the application in development mode.
Bonus: Docker 🐋
Docker is one of the driving forces of this runtime injection requirement. This containerization technology encourage us to have an optimized and built version of our application (a Docker image), before running it elsewhere. For this reason, the implementation of the proposed runtime injection mechanism directly addresses the injection of environment variables into Dockerized React applications.
To integrate the described procedure with an already defined Dockerfile
, just:
- Copy the
scripts
folder to the image. - Change the container launching command to include
./scripts/parse-env.sh
. - Add the parsing script output file to the
.dockerignore
(it may cause bugs otherwise).
Summary
This blog post introduces a standardized and mode-wide consistent mechanism to inject user-defined values into built React applications. In addition, it helps clarify common misconceptions that entry-level developers may have about how React makes certain env. variable values available within process.env
(which only applies to pre-built applications).
The proposed solution generates an on-the-fly environment.js
file, harvesting environment variables from the runtime environment, to override the default .env
values. At the end, this generated file is imported into the application through the use of a <script>
tag within index.html
.
Final notes
I would love to hear from experienced React developers. How do you fight this limitation? Do you use any simplified procedure to achieve the same goals?
Let me know at 🐦 @Sinclert_95.