#
Use environment variables
Before going forward with this guide, make sure that you completed the Setup Mock Service Worker guide.
Environment variables are incredibly useful when working with multiple environments, such as dev
, staging
, and production
, by decoupling configuration from the code. This allows to change an application's behavior without modifying the code itself. A common example is the URLs of dedicated API services, where each environment uses a different URL.
In webpack, environment variables are typically passed from the CLI to the application code using the DefinePlugin and accessed through process.env
, e.g. process.env.BASE_API_URL
.
While accessing environment variables from process.env
works, it has a few downsides:
- It's not ideal for testing. Tests relying on global variables can inadvertently affect other tests, introducing potential issues that affect test reliability, maintainability, and isolation.
- It complicates module development in isolation. A modular application shell often makes requests to multiple endpoints, which vary depending on the environment. These endpoints require environment variables to define their URLs. When developing modules in isolation, modules should not provide these environment variables to the shell. Instead, to improve DX, the shell library should manage these environment variables internally.
To replace process.env
, Squide provides the EnvironmentVariablesPlugin. This plugin acts as a registry and integrates with the Runtime API, allowing modules to register and retrieve environment variables.
Before this plugin, page components would directly rely on process.env
:
import { fetchJson } from "@sample/shared";
import { useSuspenseQuery } from "@tanstack/react-query";
export function Page() {
const { data } = useSuspenseQuery({ queryKey: [`${process.env.baseApiUrl}/getData`], queryFn: () => {
return fetchJson(`${process.env.baseApiUrl}/getData`);
} });
return (
<div>{JSON.stringify(data)}</div>
);
}
With the EnvironmentVariablesPlugin
, a page component can now retrieve the baseApiUrl
from Squide's runtime instance by using the useEnvironmentVariable hook:
import { useEnvironmentVariable } from "@squide/env-vars";
import { fetchJson } from "@sample/shared";
import { useSuspenseQuery } from "@tanstack/react-query";
export function Page() {
const baseApiUrl = useEnvironmentVariable("baseApiUrl");
const { data } = useSuspenseQuery({ queryKey: [`${baseApiUrl}/getData`], queryFn: () => {
return fetchJson(`${baseApiUrl}/getData`);
} });
return (
<div>{JSON.stringify(data)}</div>
);
}
Let's go through the setup of the plugin and how to handle a few use cases.
#
Install the package
First, open an existing Squide application. Then open a terminal at the root of the host application and install the following package:
pnpm add @squide/env-vars
yarn add @squide/env-vars
npm install @squide/env-vars
While you can use any package manager to develop an application with Squide, it is highly recommended that you use PNPM as the guides has been developed and tested with PNPM.
#
Setup the plugin
Then, update the host application boostrapping code to register an instance of the EnvironmentVariablesPlugin with the FireflyRuntime instance:
import { createRoot } from "react-dom/client";
import { ConsoleLogger, RuntimeContext, FireflyRuntime, boostrap, type RemoteDefinition } from "@squide/firefly";
import { EnvironmentVariablesPlugin } from "@squide/env-vars";
import { App } from "./App.tsx";
import { registerHost } from "./register.tsx";
import { registerShell } from "@sample/shell";
const Remotes: RemoteDefinition[] = [
{ url: name: "remote1" }
];
const runtime = new FireflyRuntime({
plugins: [x => new EnvironmentVariablesPlugin(x)],
loggers: [x => new ConsoleLogger(x)]
});
await bootstrap(runtime, {
localModules: [registerShell, registerHost],
remotes: Remotes
})
const root = createRoot(document.getElementById("root")!);
root.render(
<RuntimeContext.Provider value={runtime}>
<App />
</RuntimeContext.Provider>
);
#
Module augmentation
Before registering variables, modules must augment the EnvironmentVariables
TypeScript interface with the variables they intend to register to ensure type safety and autocompletion.
To do this, first create a types
folder:
host
├── src
├────── register.tsx
├────── Page.tsx
├────── index.tsx
├────── App.tsx
├── types
├────── env-vars.d.ts
Then create an env-vars.d.ts
file:
import "@squide/env-vars";
declare module "@squide/env-vars" {
interface EnvironmentVariables {
// In the example above, the module only intends to register the `baseApiUrl` environment variable.
baseApiUrl: string;
}
}
Finally, update the module tsconfig.json
to include the types
folder:
{
"extends": "@workleap/typescript-configs/web-application.json",
"include": ["src", "types"],
"exclude": ["dist", "node_modules"]
}
#
Register variables
Now, let's register our first variable. We recommend registering environment variables in the module's register function as it's the most logical place to access the runtime instance:
import { PublicRoutes, ProtectedRoutes, type ModuleRegisterFunction, type FireflyRuntime } from "@squide/firefly";
import { getEnvironmentVariablesPlugin } from "@squide/env-vars";
import { HomePage } from "./HomePage.tsx";
import { NotFoundPage } from "./NotFoundPage.tsx";
import { RootLayout } from "./RootLayout.tsx";
function registerEnvironmentVariables(runtime: FireflyRuntime) {
const plugin = getEnvironmentVariablesPlugin(runtime);
plugin.registerVariable("baseApiUrl", "https://my-domain.com/api");
}
export const registerHost: ModuleRegisterFunction<FireflyRuntime> = runtime => {
registerEnvironmentVariables(runtime);
runtime.registerRoute({
element: <RootLayout />,
children: [
// Placeholders indicating where non hoisted or nested public and protected routes will be rendered.
PublicRoutes,
ProtectedRoutes
]
}, {
hoist: true
});
runtime.registerPublicRoute({
path: "*",
element: <NotFoundPage />
});
runtime.registerRoute({
index: true,
element: <HomePage />
});
};
If multiple modules need to use the same environment variable, we recommend that each module register its own instance of the variable to maintain modularity. The EnvironmentVariablesPlugin
registry will ignore any subsequent registrations for existing keys, as long as the variable value remains the same.
#
Retrieve a variable in React code
Then, retrieve the variables in a new DataPage
component using either the useEnvironmentVariable or useEnvironmentVariables hook:
import { useEnvironmentVariable } from "@squide/env-vars";
import { fetchJson } from "@sample/shared";
import { useSuspenseQuery } from "@tanstack/react-query";
export function DataPage() {
const baseApiUrl = useEnvironmentVariable("baseApiUrl");
const { data } = useSuspenseQuery({ queryKey: [`${baseApiUrl}/getData`], queryFn: () => {
return fetchJson(`${baseApiUrl}/getData`);
} });
return (
<div>{JSON.stringify(data)}</div>
);
}
Finally, register a route for the component:
import { PublicRoutes, ProtectedRoutes, type ModuleRegisterFunction, type FireflyRuntime } from "@squide/firefly";
import { getEnvironmentVariablesPlugin } from "@squide/env-vars";
import { HomePage } from "./HomePage.tsx";
import { NotFoundPage } from "./NotFoundPage.tsx";
import { DataPage } from "./DataPage.tsx";
import { RootLayout } from "./RootLayout.tsx";
function registerEnvironmentVariables(runtime: FireflyRuntime) {
const plugin = getEnvironmentVariablesPlugin(runtime);
plugin.registerVariable("baseApiUrl", "https://my-domain.com/api");
}
export const registerHost: ModuleRegisterFunction<FireflyRuntime> = runtime => {
registerEnvironmentVariables(runtime);
runtime.registerRoute({
element: <RootLayout />,
children: [
// Placeholders indicating where non hoisted or nested public and protected routes will be rendered.
PublicRoutes,
ProtectedRoutes
]
}, {
hoist: true
});
runtime.registerPublicRoute({
path: "*",
element: <NotFoundPage />
});
runtime.registerRoute({
index: true,
element: <HomePage />
});
runtime.registerRoute({
path: "data",
element: <DataPage />
});
};
#
Retrieve a variable for MSW handlers
Next, create an MSW handlers for the DataPage
component. An handler can retrieve the variables using either the plugin's getVariable or getVariables function:
import type { FireflyRuntime } from "@squide/firefly";
import { getEnvironmentVariablesPlugin } from "@squide/env-vars";
import { http, HttpResponse, type HttpHandler } from "msw";
// Must specify the return type, otherwise we get a TS2742: The inferred type cannot be named without a reference to X. This is likely not portable.
// A type annotation is necessary.
export function getApiHandlers(runtime: FireflyRuntime): HttpHandler[] {
const apiBaseUrl = getEnvironmentVariablesPlugin(runtime).getVariable("apiBaseUrl");
return [
http.get(`${apiBaseUrl}/getData`, () => {
return HttpResponse.json({
"foo": "bar"
});
});
];
}
Finally, register the new handler:
import { PublicRoutes, ProtectedRoutes, type ModuleRegisterFunction, type FireflyRuntime } from "@squide/firefly";
import { getEnvironmentVariablesPlugin } from "@squide/env-vars";
import { HomePage } from "./HomePage.tsx";
import { NotFoundPage } from "./NotFoundPage.tsx";
import { DataPage } from "./DataPage.tsx";
import { RootLayout } from "./RootLayout.tsx";
function registerEnvironmentVariables(runtime: FireflyRuntime) {
const plugin = getEnvironmentVariablesPlugin(runtime);
plugin.registerVariable("baseApiUrl", "https://my-domain.com/api");
}
async function registerRequestHandlers(runtime: FireflyRuntime) {
if (runtime.isMswEnabled) {
// Files that includes an import to the "msw" package are included dynamically to prevent adding
// unused MSW stuff to the application bundles.
const requestHandlers = (await import("../mocks/api.ts")).getApiHandlers(runtime);
runtime.registerRequestHandlers(requestHandlers);
}
}
export const registerHost: ModuleRegisterFunction<FireflyRuntime> = async runtime => {
registerEnvironmentVariables(runtime);
await registerRequestHandlers(runtime);
runtime.registerRoute({
element: <RootLayout />,
children: [
// Placeholders indicating where non hoisted or nested public and protected routes will be rendered.
PublicRoutes,
ProtectedRoutes
]
}, {
hoist: true
});
runtime.registerPublicRoute({
path: "*",
element: <NotFoundPage />
});
runtime.registerRoute({
index: true,
element: <HomePage />
});
runtime.registerRoute({
path: "data",
element: <DataPage />
});
};
#
Try it 🚀
Start the application in a development environment using the dev
script and navigate to the /data
page. The page should render { "foo": "bar" }
.
#
Troubleshoot issues
If you are experiencing issues with this section of the guide:
- Open the DevTools console. You'll find a log entry for each registration that occurs (including MSW request handlers) and error messages if something went wrong.
- Refer to a working example on GitHub.
- Refer to the troubleshooting page.
#
Integrate with tests
If the code under test uses environment variables, the EnvironmentVariablesPlugin
can be used to mock these variables.
Considering the following utility hook:
export function useAbsoluteUrl(path: string) {
const apiBaseUrl = useEnvironmentVariable("apiBaseUrl");
return `${apiBaseUrl}/${path}`;
}
You can write the following unit test to mock the value of apiBaseUrl
and test the ouput of the useAbsoluteUrl
hook:
import { RuntimeContext, FireflyRuntime } from "@squide/firefly";
import { EnvironmentVariablesPlugin, getEnvironmentVariablesPlugin } from "@squide/env-vars";
import { renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { useAbsoluteUrl } from "../src/useAbsoluteUrl.ts";
test("an absolute URL including the API base URL is returned", () => {
const runtime = new FireflyRuntime({
plugins: [x => new EnvironmentVariablesPlugin(x)]
});
const plugin = getEnvironmentVariablesPlugin(runtime);
plugin.registerVariable("apiBaseUrl", "https://foo.com");
const { result } = renderHook(() => useAbsoluteUrl("bar"), {
wrapper: ({ children }: { children?: ReactNode }) => (
<RuntimeContext.Provider value={runtime}>
{children}
</RuntimeContext.Provider>
)
})
expect(result).toBe("https://foo.com/bar");
});
#
Integrate with stories
Components included in Storybook stories often rely on environment variables. The EnvironmentVariablesPlugin
can be used to mock these variables:
import { FireflyRuntime, RuntimeProvider } from "@squide/firefly";
import { EnvironmentVariablesPlugin, getEnvironmentVariablesPlugin } from "@squide/env-vars";
import type { Preview } from "@storybook/react";
const runtime = new FireflyRuntime({
plugins: [x => new EnvironmentVariablesPlugin(x)]
});
const plugin = getEnvironmentVariablesPlugin(runtime);
plugin.registerVariable("apiBaseUrl", "https://foo.com");
const preview: Preview = {
decorators: [Story => {
return (
<RuntimeProvider runtime={runtime}>
<Story />
</RuntimeProvider>
);
}]
};
export default preview;
#
Integrate with libraries
Libraries, such as an application Shell, often need to manage environment variables internally to be portable.
Assuming the host application registers the EnvironmentVariablesPlugin
into the runtime, and given the following file structure:
shell
├── src
├────── register.tsx
├── types
├────── env-vars.d.ts
To setup such a library, first, create a registerShell
function that accepts an argument indicating the current environment (e.g., dev
, staging
or production
):
import type { ModuleRegisterFunction } from "@squide/firefly";
export type Environment = "dev" | "staging" | "production";
export function registerShell(env: Environment) {
const register: ModuleRegisterFunction = runtime => {
...
}
}
#
Register variables
Then, update the registerShell
function to register the apiBaseUrl
environment variable based on the provided env
argument:
import type { ModuleRegisterFunction } from "@squide/firefly";
import { getEnvironmentVariablesPlugin } from "@squide/env-vars";
export type Environment = "dev" | "staging" | "production";
export interface ShellEnvironmentVariables {
apiBaseUrl: string;
}
const DevEnvironmentVariables: ShellEnvironmentVariables = {
apiBaseUrl: "https://dev.com"
};
const StagingEnvironmentVariables: ShellEnvironmentVariables = {
apiBaseUrl: "https://staging.com"
};
const ProductionEnvironmentVariables: ShellEnvironmentVariables = {
apiBaseUrl: "https://production.com"
};
function getEnvironmentVariables(env: Environment): ShellEnvironmentVariables {
switch (env) {
case "dev": {
return DevEnvironmentVariables;
}
case "staging": {
return StagingEnvironmentVariables;
}
case "production": {
return ProductionEnvironmentVariables;
}
default: {
throw new Error(`[shell] Unknown environment "${env}".`);
}
}
}
export function registerShell(env: Environment) {
const register: ModuleRegisterFunction = runtime => {
const variables = getEnvironmentVariables(env);
const plugin = getEnvironmentVariablesPlugin(runtime);
runtime.registerVariables(variables);
}
}
#
Module augmentation
Then, augment the EnvironmentVariables
TypeScript interface to include the apiBaseUrl
variable:
import "@squide/env-vars";
declare module "@squide/env-vars" {
interface EnvironmentVariables {
// In the example above, the module only intends to register the `baseApiUrl` environment variable.
baseApiUrl: string;
}
}
Then, update the module tsconfig.json
to include the types
folder:
{
"extends": "@workleap/typescript-configs/web-application.json",
"include": ["src", "types"],
"exclude": ["dist", "node_modules"]
}
Finally, when tsc is linting the codebase, it expects every code library that augments the EnvironmentVariables
interface to include its typings. To do this, add each code library's types
folder to every projects' tsconfig.json file that depends on code libraries:
{
"extends": "@workleap/typescript-configs/web-application.json",
"include": [
"src",
"types"
"../shell/types"
],
"exclude": ["dist", "node_modules"]
}
{
"extends": "@workleap/typescript-configs/library.json",
"include": [
"src",
"types"
"../shell/types"
],
"exclude": ["dist", "node_modules"]
}
{
"extends": "@workleap/typescript-configs/web-application.json",
"include": [
"src",
"types"
"../shell/types"
],
"exclude": ["dist", "node_modules"]
}
#
Troubleshoot issues
If you are experiencing issues with this guide:
- Open the DevTools console. You'll find a log entry for each environment variable registration that occurs and error messages if something went wrong:
[squide] The following environment variables has been registered: {...}
- Refer to a working example on GitHub.
- Refer to the troubleshooting page.