Skip to content
This repository has been archived by the owner on Jun 11, 2024. It is now read-only.

Provide a typesafe runtime configuration inside a react app

License

Notifications You must be signed in to change notification settings

contiamo/react-runtime-config

Repository files navigation

react-runtime-config

Make your application easily configurable.

A simple way to provide runtime configuration for your React application, with localStorage overrides and hot-reload support ⚡️!

npm (tag) travis (tag) license MIT (tag)

Why

Most web applications usually need to support and function within a variety of distinct environments: local, development, staging, production, on-prem, etc. This project aims to provide flexibility to React applications by making certain properties configurable at runtime, allowing the app to be customized based on a pre-determined configmap respective to the environment. This is especially powerful when combined with Kubernetes configmaps.

Here are examples of some real-world values that can be helpful when configurable at runtime:

  • Primary Color
  • Backend API URL
  • Feature Flags

How

The configuration can be set by either:

  • setting a configuration property on window with reasonable defaults. Consider,
window.MY_APP_CONFIG = {
  primaryColor: "green",
};
  • or by setting a value in localStorage. Consider,
localStorage.setItem("MY_APP_CONFIG.primaryColor", "green");

The localStorage option could provide a nice delineation between environments: you could set your local environment to green, and staging to red for example, in order to never be confused about what you're looking at when developing locally and testing against a deployed development environment: if it's green, it's local.

This configuration is then easily read by the simple React hook that this library exports.

Getting started

  1. npm i react-runtime-config
  2. Create a namespace for your config:
// components/Config.tsx
import createConfig from "react-runtime-config";

/**
 * `useConfig` and `useAdminConfig` are now React hooks that you can use in your app.
 *
 * `useConfig` provides config getter & setter, `useAdminConfig` provides data in order
 * to visualize your config map with ease. More on this further down.
 */
export const { useConfig, useAdminConfig } = createConfig({
  namespace: "MY_APP_CONFIG",
  schema: {
    color: {
      type: "string",
      enum: ["blue" as const, "green" as const, "pink" as const], // `as const` is required to have nice autocompletion
      description: "Main color of the application",
    },
    backend: {
      type: "string",
      description: "Backend url", // config without `default` need to be provided into `window.MY_APP_CONFIG`
    },
    port: {
      type: "number", // This schema can be retrieved after in `useAdminConfig().fields`
      description: "Backend port",
      min: 1,
      max: 65535,
      default: 8000, // config with `default` don't have to be set on `window.MY_APP_CONFIG`
    },
    monitoringLink: {
      type: "custom",
      description: "Link of the monitoring",
      parser: value => {
        if (typeof value === "object" && typeof value.url === "string" && typeof value.displayName === "string") {
          // The type will be inferred from the return type
          return { url: value.url as string, displayName: value.displayName as string };
        }
        // This error will be shown if the `window.MY_APP_CONFIG.monitoringLink` can't be parsed or if we `setConfig` an invalid value
        throw new Error("Monitoring link invalid!");
      },
    },
    isLive: {
      type: "boolean",
      default: false,
    },
  },
});

You can now use the created hooks everywhere in your application. Thoses hooks are totally typesafe, connected to your configuration. This means that you can easily track down all your configuration usage across your entire application and have autocompletion on the keys.

Usage

// components/MyComponents.tsx
import react from "React";
import { useConfig } from "./Config";

const MyComponent = () => {
  const { getConfig } = useConfig();

  return <h1 style={{ color: getConfig("color") }}>My title</h1>;
};

The title will have a different color regarding our current environment.

The priority of config values is as follows:

  • localStorage.getItem("MY_APP_CONFIG.color")
  • window.MY_APP_CONFIG.color
  • schema.color.default

Namespaced useConfig hook

In a large application, you may have multiple instances of useConfig from different createConfig. So far every useConfig will return a set of getConfig, setConfig and getAllConfig.

To avoid any confusion or having to manually rename every usage of useConfig in a large application, you can use the configNamespace options.

// themeConfig.ts
export const { useConfig: useThemeConfig } = createConfig({
  namespace: "theme",
  schema: {},
  configNamespace: "theme", // <- here
});

// apiConfig.ts
export const { useConfig: useApiConfig } = createConfig({
  namespace: "api",
  schema: {},
  configNamespace: "api", // <- here
});

// App.ts
import { useThemeConfig } from "./themeConfig";
import { useApiConfig } from "./apiConfig";

export const App = () => {
  // All methods are now namespaces
  // no more name conflicts :)
  const { getThemeConfig } = useThemeConfig();
  const { getApiConfig } = useApiConfig();

  return <div />;
};

Create an Administration Page

To allow easy management of your configuration, we provide a smart react hook called useAdminConfig that provides all the data that you need in order to assemble an awesome administration page where the configuration of your app can be referenced and managed.

Note: we are using @operational/components for this example, but a UI of config values can be assembled with any UI library, or even with plain ole HTML-tag JSX.

// pages/ConfigurationPage.tsx
import { Page, Card, Input, Button, Checkbox } from "@operational/components";
import { useAdminConfig } from "./components/Config";

export default () => {
  const { fields, reset } = useAdminConfig();

  return (
    <Page title="Configuration">
      <Card title="Configuration">
        {fields.map(field =>
          field.type === "boolean" ? (
            <Checkbox key={field.key} value={field.value} label={field.key} onChange={field.set} />
          ) : (
            <Input key={field.key} value={field.value} label={field.key} onChange={field.set} />
          ),
        )}
        <Button onClick={reset}>Reset config</Button>
      </Card>
    </Page>
  );
};

You have also access to field.windowValue and field.storageValue if you want implement more advanced UX on this page.

Multiconfiguration admin page

As soon as you have more than one configuration in your project, you might want to merge all thoses configurations in one administration page. Of course, you will want a kind of ConfigSection component that take the result of any useAdminConfig() (so field, reset and namespace as props).

Spoiler alert, having this kind of component type safe can be tricky, indeed you can try use ReturnType<typeof useFirstAdminConfig> | ReturnType<typeof useSecondAdminConfig> as props but typescript will fight you (Array.map will tell you that the signature are not compatible).

Anyway, long story short, this library provide you an easy way to with this: GenericAdminFields type. This type is compatible with every configuration and will provide you a nice framework to create an amazing UX.

import { GenericAdminFields } from "react-runtime-config";

export interface ConfigSectionProps {
  fields: GenericAdminFields;
  namespace: string;
  reset: () => void;
}

export const ConfigSection = ({ namespace, fields }: ConfigSectionProps) => {
  return (
    <Section title={namespace}>
      {fields.map(f => {
        if (f.type === "string" && !f.enum) {
          return <Input key={f.key} type="text" label={f.key} onChange={f.set} value={f.value} />;
        }
        if (f.type === "number") {
          return <Input key={f.key} type="number" label={f.key} onChange={f.set} value={f.value} />;
        }
        if (f.type === "boolean") {
          return <Checkbox key={f.key} label={f.key} onChange={f.set} value={f.value} />;
        }
        if (f.type === "string" && f.enum) {
          // `f.set` can take `any` but you still have runtime validation if a wrong value is provided.
          return <Select options={f.enum} value={f.value} onChange={f.set} />;
        }
        if (f.type === "custom") {
          /* Add some special handler/typeguard to retrieve the safety */
        }
      })}
    </Section>
  );
};

PS: If you have a better idea/pattern, please open an issue to tell me about it 😃

Moar Power (if needed)

We also expose from createConfig a simple getConfig, getAllConfig and setConfig. These functions can be used standalone and do not require use of the useConfig react hooks. This can be useful for accessing or mutating configuration values in component lifecycle hooks, or anywhere else outside of render.

These functions are exactly the same as their counterparts available inside the useConfig react hook, the only thing you lose is the hot config reload.