Microfrontends in JS: Dependency injection — A cure all?

Viral Tagdiwala
9 min readAug 19, 2024

--

This article is a continuation to my series on Microfrontends in JS, refer this link for my previous article on inter module communications.

Dependency Injection is a term that I am not the biggest fan of — it sounds more a lot more daunting than it really is. Breaking the word into its parts — Dependency i.e. any module/piece of code your code relies upon and Injection refers to passing/inserting any piece of code in the software engineering space.

Combine this with the powers of Typescript and (in my honest opinion) we have an over-powered architectural approach to many problems encountered while developing micro-frontends with run-time injected components.

But what difference does this make, really?

Lets continue with our previous example to see how dependency injection can solve a lot of our (hypothetical) problems

Our microfrontend application is made up of 3 run-time injected components — the header, the chart and the table rendered underneath the chart.

Imagine, we have a scenario where our business requirements have grown to a point where we can really use the runtime injection capabilities of our chart component.

We wish to now develop another page in our application, where users would be able to generate a snapshot of their current view of the application and share it to relevant stakeholders as, say a monthly report.

While we are at it, we also wish to test a new, more efficient rendering engine for our chart, however instead of risking our primary page with a new release, we wish to try out this new engine with just this new report page.

Technical Problem Statement

Generate a single component, that can be run-time injected, can conditionally use a different rendering engine based on its render context and can also limit certain functionalities based upon its render context.

Lets try solving one problem at a time —

Problem 1:

Conditionally use a different rendering engine based upon render context

A rather easy solution that might jump to ones mind is using a prop provided by the consumer that dictates which engine to use, eg:

type NewEngineChartProps = {
newEngine: true;
otherProps: NewEngineChartPropsType
}
type LegacyChartProps = {
newEngine: false;
otherProps: LegacyEngineChartPropsType
}
type MyChartProps = NewEngineChartProps | LegacyChartProps

const MyChart = ({newEngine, otherProps}: MyChartProps) => {
if(newEngine){
return <NewChart {...otherProps}/>
}else {
return <LegacyChart {...otherProps} />
}
}

Well, that was simple!

Until… business requirements grow and you are asked to add another variant of the chart that has to render differently based upon the render context.

Lets try making this same component, but with dependency injection

type WithData<T, D> = T & { data: D };

type MyChartProps<T extends ComponentType<any>, D> = {
renderEngine: T;
renderEngineProps: WithData<ComponentProps<T>, D[]>;
dataTransformer?: (item: D) => D;
};

Let’s break this down in a simple way:

type WithData<T, D> = T & { data: D };

Imagine you have a toy box (T) and some special blocks (D). This line is like saying, “I want a toy box that definitely has these special blocks in it.” So, no matter what kind of toy box you have, it will always have these special blocks.

type MyChartProps<T extends ComponentType<any>, D> = { … }

This is like creating a recipe for a special kind of picture (chart). The recipe needs two things:
- T: A magic drawing tool (like a special crayon or paintbrush)
- D: The type of things we want to draw (like stars, circles, or squares)

Inside `MyChartProps`, we have:

renderEngine: T;

This is saying, We need that magic drawing tool we talked about.

renderEngineProps: WithData<ComponentProps<T>, D[]>;

This is like saying,
We need all the usual stuff our magic drawing tool needs (like paper and colors), but we also definitely need a box of the special things we want to draw.

dataTransformer?: (item: D) => D;

This is like saying, We might also have a magic wand that can change our special things before we draw them, but we don’t have to have it.

So, in simple terms, this whole thing is a recipe for making a special picture. It needs a magic drawing tool, all the usual stuff for that tool plus a box of special things to draw, and maybe a magic wand to change those special things.

Finally, we can generate our chart like so

function MyChart<T extends ComponentType<any>, D>({ 
renderEngine: ChartComponent,
renderEngineProps,
dataTransformer
}: MyChartProps<T, D>) {
// Transform data if a transformer is provided
const transformedData = dataTransformer
? transformData(renderEngineProps.data, dataTransformer)
: renderEngineProps.data;

// Filter props to only include 'width', 'height', and 'style'
const filteredProps = filterProps(renderEngineProps, ['width', 'height', 'style']);

return <ChartComponent {...filteredProps} data={transformedData} />;
}

We then define two types of charts, legacy and new

type DataPoint = { x: number; y: number };

const LegacyChartComponent: React.FC<{
data: DataPoint[];
width?: number;
height?: number;
style?: React.CSSProperties
}> = (props) => {
// Imagine this is an actual chart component
return <div>Legacy Chart</div>;
};
type DataPoint = { x: number; y: number };

const LegacyChartComponent: React.FC<{
data: DataPoint[];
width?: number;
height?: number;
style?: React.CSSProperties
}> = (props) => {
// Imagine this is an actual chart component
return <div>New Chart</div>;
};

And finally it can be used like

const App: React.FC = () => {
const data: DataPoint[] = [{ x: 1, y: 2 }, { x: 2, y: 4 }, { x: 3, y: 6 }];

const dataTransformer = (point: DataPoint) => ({ ...point, y: point.y * 2 });

return (
<MyChart<typeof LegacyChartComponent, DataPoint>
renderEngine={LegacyChartComponent}
renderEngineProps={{
data,
width: 500,
height: 300,
style: { border: '1px solid black' },
extraProp: 'This will be filtered out'
}}
dataTransformer={dataTransformer}
/>
);
};

Before I get into the question of “why” this approach is better, lets try solving problem 2 with Dependency Injection now that we are familiar with it

Problem 2

Lets say, the feature we wish to limit is any interactions on the chart itself

We start off with defining our interfaces for the feature interactions

// Define a set of possible features
type ChartFeature = 'zooming' | 'annotations' | 'legend' | 'tooltips' | 'gridLines';

// Define the shape of our feature controllers
type FeatureControllers = {
[K in ChartFeature]: {
isEnabled: () => boolean;
toggle: () => void;
}
};

// Create a context for our feature controllers
const FeatureContext = React.createContext<FeatureControllers | null>(null);

// HOC to inject feature controllers
function withFeatures<T extends ComponentType<any>>(WrappedComponent: T): ComponentType<ComponentProps<T>> {
return (props: ComponentProps<T>) => {
const featureControllers = React.useContext(FeatureContext);
if (!featureControllers) {
throw new Error("Feature context not found");
}
return <WrappedComponent {...props} features={featureControllers} />;
};
}
  1. Defining ChartFeature:

type ChartFeature = ‘zooming’ | ‘annotations’ | ‘legend’ | ‘tooltips’ | ‘gridLines’;

2. Defining FeatureControllers:


type FeatureControllers = {
[K in ChartFeature]: {
isEnabled: () => boolean;
toggle: () => void;
}
};

/**
This is creating an object type where:
- The keys are all the possible `ChartFeature`s we defined earlier.
- Each key has an object as its value with two functions:
— `isEnabled`: a function that returns a boolean (true or false)
— `toggle`: a function that doesn’t return anything (void)
**/

This structure allows us to control each feature individually.

3. Creating FeatureContext:


const FeatureContext = React.createContext<FeatureControllers | null>(null);

This creates a React Context. Context is a way to pass data through the component tree without having to pass props down manually at every level. We’re creating a context that will hold our `FeatureControllers` (or null if it’s not set).

4. Higher-Order Component (HOC) withFeatures:

function withFeatures<T extends ComponentType<any>>(WrappedComponent: T): ComponentType<ComponentProps<T>> {
return (props: ComponentProps<T>) => {
const featureControllers = React.useContext(FeatureContext);
if (!featureControllers) {
throw new Error(“Feature context not found”);
}
return <WrappedComponent {…props} features={featureControllers} />;
};
}

This is a Higher-Order Component (HOC). It’s a function that takes a component and returns a new component with some added functionality. Here’s what it does:

- It’s generic, meaning it can work with any type of component.
- It returns a new function component that:
1. Uses `useContext` to get the `featureControllers` from our `FeatureContext`.
2. Checks if `featureControllers` exists, throwing an error if it doesn’t.
3. Renders the original component (`WrappedComponent`) with all its original props, plus a new `features` prop containing the `featureControllers`.

This HOC allows us to easily inject the feature controllers into any component that needs them, without having to pass them down through props at every level.

We now have to generate a chart component that can use these features

function MyChart<T extends ComponentType<any>, D>({ 
renderEngine: ChartComponent,
renderEngineProps,
dataTransformer
}: MyChartProps<T, D>) {
// Transform data if a transformer is provided
const transformedData = dataTransformer
? renderEngineProps.data.map(dataTransformer)
: renderEngineProps.data;

const EnhancedChartComponent = withFeatures(ChartComponent);

return <EnhancedChartComponent {...renderEngineProps} data={transformedData} />;
}
const LegacyChartComponent: React.FC<WithData<{
width?: number;
height?: number;
style?: React.CSSProperties;
features: FeatureControllers;
}, DataPoint>> = ({ data, features, ...props }) => {
return (
<div>
Chart with {data.length} points
<br />
Zooming: {features.zooming.isEnabled() ? 'Enabled' : 'Disabled'}
<button onClick={features.zooming.toggle}>Toggle Zoom</button>
</div>
);
};

Lets see how we will actually use it

// Feature provider component
const FeatureProvider: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
const [features, setFeatures] = React.useState<Record<ChartFeature, boolean>>({
zooming: true,
annotations: true,
legend: true,
tooltips: true,
gridLines: true
});

const featureControllers: FeatureControllers = Object.keys(features).reduce((acc, feature) => {
acc[feature as ChartFeature] = {
isEnabled: () => features[feature as ChartFeature],
toggle: () => setFeatures(prev => ({ ...prev, [feature]: !prev[feature as ChartFeature] }))
};
return acc;
}, {} as FeatureControllers);

return (
<FeatureContext.Provider value={featureControllers}>
{children}
</FeatureContext.Provider>
);
};
const App: React.FC = () => {
const data: DataPoint[] = [{ x: 1, y: 2 }, { x: 2, y: 4 }, { x: 3, y: 6 }];

const dataTransformer = (point: DataPoint) => ({ ...point, y: point.y * 2 });

return (
<FeatureProvider>
<MyChart<typeof LegacyChartComponent, DataPoint>
renderEngine={LegacyChartComponent}
renderEngineProps={{
data,
width: 500,
height: 300,
style: { border: '1px solid black' },
}}
dataTransformer={dataTransformer}
/>
</FeatureProvider>
);
};

Here’s a TL;DR of this approach

We’ve created a FeatureContext to hold our feature controllers.

We’ve implemented a withFeatures HOC that injects the feature controllers into any component.

The MyChart component now uses this HOC to enhance the provided chart component with feature controls.

We’ve created a FeatureProvider component that manages the state of features and provides the feature controllers through the context.

The chart component (LegacyChartComponent in this case) now receives features as a prop, which contains methods to check if a feature is enabled and to toggle it.

In the App component, we wrap our MyChart with the FeatureProvider.

Dependency Injection for the WIN!

Here are the upsides to using this architectural pattern (especially in cases where we wish to develop extensible components injectable at run time)

  1. Flexibility and Extensibility: The system can easily accommodate new chart engines, components, or features without modifying the core structure, allowing for seamless expansion of functionality.
  2. Separation of Concerns: Component rendering, feature management, and data transformation are decoupled, allowing each part of the system to focus on its specific responsibility.
  3. Dynamic Configuration: Both components and features can be dynamically selected, enabled, or modified at runtime, providing a highly customizable and interactive user experience.
  4. Type Safety and Compile-time Checks: The use of TypeScript and generics ensures that correct props, components, and feature controllers are used, reducing runtime errors and improving code reliability.
  5. Reusability and Consistency: Common logic for feature management and component wrapping is centralized, promoting code reuse and ensuring consistent behavior across different parts of the application.
  6. Testability and Maintainability: Components, features, and data transformations can be easily tested in isolation by mocking injected dependencies, leading to more robust and maintainable code.
  7. Scalability: As the application grows, new features or chart types can be added with minimal changes to existing code, facilitating easier updates and reducing the risk of introducing bugs.

Here’s a simple analogy to drive my point across:

The Dependency Injection based approach is like having a universal remote control that can work with any TV brand. You just need to tell it which TV you’re using.

The prop drilling approach is like having a remote control with specific buttons for each TV brand. If you get a new TV brand, you need to modify the remote control.

While the prop drilling approach might seem simpler for just two chart types, the generic approach provides much more flexibility and scalability in the long run, especially if you anticipate adding more chart types or want to keep your options open for future changes.

--

--

Viral Tagdiwala
Viral Tagdiwala

No responses yet