Federated Design System with Module Federation and Styled Components

Authors
  • avatar
    Name
    Austin Howard
Updated on

In this post we will walk through the project at a high level and pick apart some of the interesting details. You can clone this project and use it for yourself.

git clone https://github.com/ahoward2/federated-design-system.git

Overview

What is a design system, and more specifically what is this federated design system?

We'll first answer these questions and then we'll get into the actual project.

  1. What is a design system?

In short, a design system is a set of design principles, guidelines, tools, components that can be used to create consistent user interfaces.

  1. What is this federated design system?

This is a design system that is built with both build-time and run-time consumption in mind.

  1. What is the difference between build-time and run-time consumption in this case?

In Node Package Manager (NPM) world, application developers can install packages into their applications while they are building them. This is called build-time consumption. You're probably familiar with this - yarn add, npm install, or pnpm add.

When an application is running, it is running in a run-time environment. So how does this run-time environment know what packages to use? Well, we can use a webpack feature called Module Federation to do this type of thing.

Module Federation is a webpack feature that allows you to share code between different webpack bundles.

Pretty cool, right? No need for you to install packages the typical way. Interestingly enough this means we can also upgrade or downgrade packages without having to restart the application. Kinda dangerous but also powerful at the same time.

Let's take a look at what this looks like from a diagram.

  1. The design system publishes a new version to the public NPM registry.
  2. All NPM packages are immediately available on unkpg.com. This is a free open source content delivery network that is powered by Cloudflare and Fly.io.
  3. Our host app is aware of any new versions of the design system and automatically updates itself. Currently, our host app is configured to use the latest minor or patch versions of the design system. This behavior can be changed and could even be controlled from an external system.

Depending on the number of host applications consuming the design system, as soon as a design system update goes live, we could have any number of applications update instantly. This can be particularly useful if there are a large number of applications that have long deployment life-cycles.

The Nitty Gritty

Let's take a look at some of the code.

The project has two applications:

  • design-system
  • host-app

In a realistic scenario, these applications would each be in their own codebase and be tracked independently. For the purpose of this post, they're together in one codebase.

design-system

This is the design system. It's a Typescript based React library that is built with Styled Components, and includes storybook for a development environment.

Some features of the design system are:

  • 3 starter atomic components
    • Button
    • Message Box
    • ThemeToggle
  • A theme context
  • Theme style definitions
  • A custom hook for toggling themes
  • A custom webpack configuration to enable module federation publishing

The real magic happens in the webpack.config.js file.

webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')
const path = require('path')
const { camelCase } = require('camel-case')
const { merge } = require('webpack-merge')

const pkg = require('./package.json')
const name = camelCase(pkg.name)

const deps = require('./package.json').dependencies

const baseConfig = {
  mode: process.env.NODE_ENV === 'development' ? 'development' : 'production',
  resolve: {
    extensions: ['.tsx', '.ts', '.jsx', '.js', '.json', '.md'],
  },
  module: {
    rules: [
      {
        test: /\.m?js/,
        type: 'javascript/auto',
        resolve: {
          fullySpecified: false,
        },
      },
      {
        test: /\.css$/i,
        use: ['style-loader', 'css-loader'],
      },
      {
        test: /\.jsx$/,
        loader: 'esbuild-loader',
        options: {
          loader: 'jsx',
          target: 'es2015',
        },
      },
      {
        test: /\.tsx?$/,
        loader: 'esbuild-loader',
        options: {
          loader: 'tsx',
          target: 'es2015',
        },
      },
    ],
  },
}

const browserConfig = {
  output: {
    path: path.resolve('./dist/browser'),
  },
  plugins: [
    new ModuleFederationPlugin({
      name,
      filename: 'remote-entry.js',
      remotes: {},
      exposes: {
        './ThemeToggle': './src/components/ThemeToggle',
        './GlobalStyle': './src/styles/global',
        './ThemeContext': './src/context/ThemeContext',
        './darkTheme': './src/styles/themes',
        './lightTheme': './src/styles/themes',
        './useThemeMode': './src/util/hooks/useThemeMode',
        './Button': './src/components/Button',
        './MessageBox': './src/components/MessageBox',
      },
      shared: {
        ...deps,
        react: {
          singleton: true,
          requiredVersion: deps.react,
        },
        'react-dom': {
          singleton: true,
          requiredVersion: deps['react-dom'],
        },
        'styled-components': {
          singleton: true,
          requiredVersion: deps['styled-components'],
        },
      },
    }),
  ],
}



module.exports = [merge(baseConfig, browserConfig)]

We grab the ModuleFederationPlugin from webpack and configure it to expose some of our design system code. We also add some shared dependencies to the remote bundle. Adding new components to expose is a manual process, but you could set up a function to scan the components and util directories to automatically expose any exports from them. For this post we keep it simple and manually configure everything.

host-app

This is the host app. It's a Typescript based React application that consumes the design system. The host app is as basic as possible. Ideally it's job would be to facilitate consumption of remote components, layouts, routing, and a data layer. For this post, we kept it as simple as possible to demonstrate consumption of the design system only.

Some features of the host app are:

  • Shell component that renders some design system components
  • A custom webpack configuration to enable module federation consumption

Like the design system, the webpack.config.js file is where the important stuff happens. This consumption pattern was inspired by Jacob Ebey.

webpack.config.js
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");
const { camelCase } = require("camel-case");

const federatedRemotes = {
  "federated-design-system": "^0.0.1",
};

const localRemotes = {
  "federated-design-system": `${camelCase(
    "federated-design-system"
  )}@http://localhost:3001/browser/remote-entry.js`,
};

const deps = {
  ...federatedRemotes,
  ...require("./package.json").dependencies,
};

const unpkgRemote = (name) =>
  `${camelCase(name)}@https://unpkg.com/${name}@${
    deps[name]
  }/dist/browser/remote-entry.js`;

const remotes = Object.keys(federatedRemotes).reduce(
  (remotes, lib) => ({
    ...remotes,
    [lib]: unpkgRemote(lib),
  }),
  {}
);

module.exports = {
  entry: "./src/index",
  mode: "development",
  devServer: {
    static: {
      directory: path.join(__dirname, "dist"),
    },
    port: 3000,
  },
  output: {
    publicPath: "auto",
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".jsx"],
  },
  module: {
    rules: [
      {
        test: /bootstrap\.js$/,
        loader: "bundle-loader",
        options: {
          lazy: true,
        },
      },
      {
        test: /\.jsx?$/,
        loader: "esbuild-loader",
        options: {
          loader: "jsx",
          target: "es2015",
        },
      },
      {
        test: /\.tsx?$/,
        loader: "esbuild-loader",
        options: {
          loader: "tsx",
          target: "es2015",
        },
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "./public/index.html",
    }),
    new ModuleFederationPlugin({
      name: "host-app",
      remotes: process.env.LOCAL_MODULES === "true" ? localRemotes : remotes,
      exposes: {},
      shared: {
        ...deps,
        react: {
          singleton: true,
          requiredVersion: deps.react,
        },
        "react-dom": {
          singleton: true,
          requiredVersion: deps["react-dom"],
        },
        "styled-components": {
          singleton: true,
          requiredVersion: deps["styled-components"],
        },
      },
    }),
  ],
};

Like the design system, we grab the ModuleFederationPlugin from webpack and configure it to consume the design system. We add the same shared dependencies to the remote bundle - this is important so that we don't have multiple versions of React, react-dom, or styled-components.

The federatedRemotes object contains any remote dependencies and their versions. This is similar to adding package dependencies to standard way in package.json.

For local development, we have the localRemotes object. This is where we can point to local bundles instead of remote bundles. On line 82 we look for a LOCAL_MODULES environment variable to true to enable this.

Now let's take a look at how the host app is actually importing the design system components.

App.tsx
// @ts-nocheck
//
// Unfortunately, since Module Federation involves runtime consumption of components
// and hooks, we lose the ability to easily get full typescript support.
//

import React from "react";

import { ThemeProvider } from "styled-components";

// ============================================================================
// Remote Imports
// ============================================================================

const RemoteThemeToggle = React.lazy(
  () => import("federated-design-system/ThemeToggle")
);
const RemoteGlobalStyle = React.lazy(
  () => import("federated-design-system/GlobalStyle")
);

import GlobalStyle from "./styles/global";

const RemoteButton = React.lazy(() => import("federated-design-system/Button"));
const RemoteMessageBox = React.lazy(
  () => import("federated-design-system/MessageBox")
);
import { lightTheme as RemoteLightTheme } from "federated-design-system/lightTheme";
import { darkTheme as RemoteDarkTheme } from "federated-design-system/darkTheme";
import useThemeMode from "federated-design-system/useThemeMode";

const App = () => {
  const { theme, themeToggler } = useThemeMode();
  const themeMode = theme === "light" ? RemoteLightTheme : RemoteDarkTheme;
  return (
    <ThemeProvider theme={themeMode}>
      <React.Suspense fallback={<GlobalStyle />}>
        <RemoteGlobalStyle />
      </React.Suspense>
      <React.Suspense fallback="Loading Theme Toggler">
        <RemoteThemeToggle themeToggler={themeToggler} />
      </React.Suspense>
      <br></br>
      <h1>Design system</h1>
      <h2>Consumed via Module Federation</h2>
      <br></br>
      <React.Suspense fallback="loading button">
        <RemoteButton>Button with primary color</RemoteButton>
      </React.Suspense>
      <br></br>
      <React.Suspense fallback="loading message box">
        <RemoteMessageBox
          text={"Message box with secondary color"}
          messageType={"info"}
        ></RemoteMessageBox>
      </React.Suspense>
    </ThemeProvider>
  );
};

export default App;

One primary drawback of using Module Federation is that we lose the ability to use typescript. Typescript is useful at build-time, but not at runtime. Since module federation is allowing use to consume components at runtime, there's really no way to get types from the design system. There have been some workarounds to enable this but they either involve developing the applications/libraries together in a monorepo or using this approach webpack-remote-types-plugin. We're not using this plugin at the moment, but it's a good idea to keep an eye on it.

We use React.Suspense to lazy load some of the design system components, and static imports for some of the hooks and functions. For our <RemoteGlobalStyle /> component, we've specified a fallback to a local stylesheet. This isn't fully ideal, but for this project it's necessary to avoid flickering before the remote stylesheet is loaded. there may be a better way to delay loading of pages until the remote stylesheet is loaded.

Final Notes

This is a great example of how to use Module Federation to consume a design system. It's definitely not perfect but can be improved in many ways.