Component Level Ownership

Introduction to Component Level Ownership

What is Component Level Ownership (CLO)?

It's a pattern of software design that shifts as much responsibility to the component as possible. We have heard of dumb components before… well these are smart components

The idea behind them is they work standalone or near standalone. Inside of a monolithic app, you'll probably find a lot of logic colocated at the page level. Data, state, props, that are sent down to dumb components. In a smart component, the page would send hints about what the component should do. In theory, I should be able to mount any component on a CRA app, pass it some very basic props/hints, and it'll do the rest of the job.

Implementing Component Level Ownership

To achieve Component Level Ownership, each application needs to configure its Webpack Module Federation plugin with two options: name and exposes. The name option defines the unique identifier of the application, which will be used by other applications to reference it. The exposes option defines a mapping of keys to local files that contain the components to be exposed. For example:

React
Angular

Create a Self-sustaining component

The core concept of "Component Level Ownership" is to provide modules that are self sufficent, intepdent when possible. The host can pass props that tell it what to do, but its best when the component constols its own data supply and contracts.

src/ServiceComponent.jsx
import React, { Suspense } from 'react';

function fetchData(url) {
  let status = 'pending';
  let result;
  let suspender = fetch(url)
    .then((response) => response.json())
    .then((data) => {
      status = 'success';
      result = data;
    }, (error) => {
      status = 'error';
      result = error;
    });

  return {
    read() {
      if (status === 'pending') {
        throw suspender;
      } else if (status === 'error') {
        throw result;
      } else if (status === 'success') {
        return result;
      }
    }
  };
}

const resource = fetchData('https://jsonplaceholder.typicode.com/todos/1');

function HelloWorld() {
  const data = resource.read(); // This will suspend if the data isn't ready
  return <pre>{JSON.stringify(data)}</pre>;
}

export default function Wrapper() {
  return (
    <Suspense fallback={<pre>Loading...</pre>}>
      <HelloWorld />
    </Suspense>
  );
}

Expose the component

Expose the component through your plugin configuration. The method for doing this may differ depending on the tool you are using. For example, with rsbuild, you would configure the exposes option. With rspack, you would use the exposes property. And with webpack, you would use the exposes option in the ModuleFederationPlugin configuration.

rspack.config.js
new ModuleFederationPlugin({
  name: 'remote',
  exposes: {
    './ServiceComponent': './src/ServiceComponent',
  },
  filename: 'remoteEntry.js',
  shared: {
    react: {
      singleton: true,
      requiredVersion: dependencies['react'],
    },
    'react-dom': {
      singleton: true,
      requiredVersion: dependencies['react-dom'],
    },
  },
});

Import it in the host

The component should require a minimal api contract with the host since it is able to fetch it own data

app.jsx
import React from 'react';

const RemoteButton = React.lazy(() => import('remote/Button'));
const ServiceComponent = React.lazy(() => import('remote/ServiceComponent'));

const App = () => (
  <div>
    <h1>Basic Host-Remote</h1>
    <h2>Host</h2>
    <React.Suspense fallback="Loading Button">
      <RemoteButton />
      <ServiceComponent/>
    </React.Suspense>
  </div>
);

export default App;