How to override components?
Longer, detailed and not finished version
Milan Herda, 07/2024
The Problem
1. Design based on
the current portal
2. Different components for the same functionality
3. Different set of features
Let's start with an obvious approach
'use client';
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';
export default function Homepage() {
const hostname = useHostname();
let HeroBanner = DefaultHeroBanner;
if (hostname === 'prace.cz') {
HeroBanner = HeroBannerPrace;
} else if (hostname === 'jobs.cz') {
HeroBanner = HeroBannerJobs;
}
return (
<>
<HeroBanner />
<SearchForm />
<>
</>
);
}
SOLID principles to the rescue
Single Responsibility Principle
'use client';
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';
export default function Homepage() {
const hostname = useHostname();
let HeroBanner = DefaultHeroBanner;
if (hostname === 'prace.cz') {
HeroBanner = HeroBannerPrace;
} else if (hostname === 'jobs.cz') {
HeroBanner = HeroBannerJobs;
}
return (
<>
<HeroBanner />
<SearchForm />
<>
</>
);
}
Single Responsibility Principle
'use client';
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';
export default function Homepage() {
const hostname = useHostname();
const HeroBanner = resolveHeroBanner(hostname)
return (
<>
<HeroBanner />
<SearchForm />
<>
</>
);
}
function resolveHeroBanner(hostname: string, componentName: string) {
let HeroBanner = DefaultHeroBanner;
if (hostname === 'prace.cz') {
HeroBanner = HeroBannerPrace;
} else if (hostname === 'jobs.cz') {
HeroBanner = HeroBannerJobs;
}
return HeroBanner;
}
Single Responsibility Principle
'use client';
import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';
import resolveHeroBanner from '@local/resolver/application/resolveBanner';
export default function Homepage() {
const hostname = useHostname();
const HeroBanner = resolveHeroBanner(hostname)
return (
<>
<HeroBanner />
<SearchForm />
</>
);
}
Single Responsibility Principle
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
export default function resolveHeroBanner(hostname: string) {
let HeroBanner = DefaultHeroBanner;
if (hostname === 'prace.cz') {
HeroBanner = HeroBannerPrace;
} else if (hostname === 'jobs.cz') {
HeroBanner = HeroBannerJobs;
}
return HeroBanner;
}
Open-Closed Principle
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
export default function resolveHeroBanner(hostname: string) {
let HeroBanner = DefaultHeroBanner;
if (hostname === 'prace.cz') {
HeroBanner = HeroBannerPrace;
} else if (hostname === 'jobs.cz') {
HeroBanner = HeroBannerJobs;
}
return HeroBanner;
}
Open-Closed Principle
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
const componentsRegistry = {
'prace.cz': HeroBannerPrace,
'jobs.cz': HeroBannerJobs,
};
export default function resolveHeroBanner(hostname: string) {
if (componentsRegistry[hostname]) {
return componentsRegistry[hostname];
}
return DefaultHeroBanner;
}
Open-Closed Principle
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
const componentsRegistry = {
'default': DefaultHeroBanner,
'prace.cz': HeroBannerPrace,
'jobs.cz': HeroBannerJobs,
};
export default function resolveHeroBanner(hostname: string) {
if (componentsRegistry[hostname]) {
return componentsRegistry[hostname];
}
return componentsRegistry.default ?? () => null;
}
Open-Closed Principle
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
const componentsRegistry = {
'default': DefaultHeroBanner,
'prace.cz': HeroBannerPrace,
'jobs.cz': HeroBannerJobs,
};
export default function resolveComponent(hostname: string, componentName: string) {
if (componentsRegistry[hostname]) {
return componentsRegistry[hostname];
}
return componentsRegistry.default ?? () => null;
}
Open-Closed Principle
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
const componentsRegistry = {
'default': {
HeroBanner: DefaultHeroBanner,
},
'prace.cz': {
HeroBanner: HeroBannerPrace,
},
'jobs.cz': {
HeroBanner: HeroBannerJobs,
},
};
export default function resolveComponent(hostname: string, componentName: string) {
if (componentsRegistry[hostname]?.[componentName]) {
return componentsRegistry[hostname][componentName];
}
return componentsRegistry.default[componentName] ?? () => null;
}
Open-Closed Principle
import componentsRegistry from '@local/configuration/componentsRegistry';
export default function resolveComponent(hostname: string, componentName: string) {
if (componentsRegistry[hostname]?.[componentName]) {
return componentsRegistry[hostname][componentName];
}
return componentsRegistry.default[componentName] ?? () => null;
}
What about different non-component services per hostname?
Renaming to the rescue!
import componentsRegistry from '@local/configuration/componentsRegistry';
export default function resolveComponent(hostname: string, componentName: string) {
if (componentsRegistry[hostname]?.[componentName]) {
return componentsRegistry[hostname][componentName];
}
return componentsRegistry.default[componentName] ?? () => null;
}
import serviceRegistry from '@local/configuration/serviceRegistry';
export default function resolveService(hostname: string, serviceName: string) {
if (serviceRegistry[hostname]?.[serviceName]) {
return serviceRegistry[hostname][serviceName];
}
return serviceRegistry.default[serviceName] ?? () => null;
}
⬇️
ServiceRegistry
// src/configuration/serviceRegistry
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import JobOfferRepository from '@search/jobs/repository/JobOfferRepository';
const serviceRegistry = {
'default': {
HeroBanner: DefaultHeroBanner,
jobOfferRepository: JobOfferRepository,
},
'prace.cz': {
HeroBanner: HeroBannerPrace,
},
'jobs.cz': {
HeroBanner: HeroBannerJobs,
},
};
export default serviceRegistry;
What about different parameters per hostname?
ServiceRegistry => Registry
// src/configuration/serviceRegistry
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import jobOfferRepository from '@search/jobs/repository/jobOfferRepository';
const serviceRegistry = {
'default': {
HeroBanner: DefaultHeroBanner,
jobOfferRepository: jobOfferRepository,
},
'prace.cz': {
HeroBanner: HeroBannerPrace,
},
'jobs.cz': {
HeroBanner: HeroBannerJobs,
},
};
export default serviceRegistry;
ServiceRegistry => Registry
// src/configuration/serviceRegistry
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import jobOfferRepository from '@search/jobs/repository/jobOfferRepository';
const registry = {
services: {
'default': {
HeroBanner: DefaultHeroBanner,
jobOfferRepository: jobOfferRepository,
},
'prace.cz': {
HeroBanner: HeroBannerPrace,
},
'jobs.cz': {
HeroBanner: HeroBannerJobs,
},
}
};
export default registry;
ServiceRegistry => Registry
// src/configuration/registry
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import jobOfferRepository from '@search/jobs/repository/jobOfferRepository';
const registry = {
services: {
'default': {
HeroBanner: DefaultHeroBanner,
jobOfferRepository: jobOfferRepository,
},
'prace.cz': {
HeroBanner: HeroBannerPrace,
},
'jobs.cz': {
HeroBanner: HeroBannerJobs,
},
},
parameters: {
'default': {
searchEngine: 'job-matching',
},
'prace.cz': {
searchEngine: 'algolia',
},
'jobs.cz': {
searchEngine: 'elastic'
},
},
};
export default registry;
ServiceRegistry => Registry
const registry = {
components: {
'default': {
HeroBanner: DefaultHeroBanner,
},
'prace.cz': {
HeroBanner: HeroBannerPrace,
},
'jobs.cz': {
HeroBanner: HeroBannerJobs,
},
},
services: {
'default': {
jobOfferRepository: jobOfferRepository,
},
},
parameters: {
'default': {
searchEngine: 'job-matching',
},
'prace.cz': {
searchEngine: 'algolia',
},
'jobs.cz': {
searchEngine: 'elastic'
},
},
};
export default registry;
"Resolvers"
import registry from '@local/configuration/registry';
export function resolveComponent(hostname: string, componentName: string) {
const { components } = registry;
if (components[hostname]?.[componentName]) {
return components[hostname][componentName];
}
return components.default?.[componentName] ?? () => null;
}
export function resolveService(hostname: string, serviceName: string) {
const { services } = registry;
if (services[hostname]?.[serviceName]) {
return services[hostname][serviceName];
}
return services.default?.[serviceName];
}
export function getParameter(hostname: string, name: string) {
const { parameters } = registry;
if (parameters[hostname]?.[name]) {
return parameters[hostname][name];
}
return parameters.default?.[name];
}
More issues to solve
- we do not have working types on resolved components
- configuration file imports all components and services for the whole website and every domain
- how do we create a configuration file?
- what if we include a React Server Component into the configuration and use it from client component?
- how to use it from other packages?
Working TypeScript types
Working types: Naive approach
- we will introduce a requirement on developers to provide named types/interfaces on each overridable component/service
- we use this type when obtaining service from the registry
Working types: Naive approach
export function resolveComponent<T>(hostname: string, componentName: string): T {
// ...
}
export function resolveService<T>(hostname: string, serviceName: string): T {
// ...
}
'use client';
import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';
import { ComponentType } from 'react';
import type { HeroBannerProps } from '@local/ui/homepage/HeroBanner';
export default function Homepage() {
const hostname = useHostname();
const HeroBanner = resolveComponent<ComponentType<HeroBannerProps>>(hostname, 'HeroBanner')
return (
<>
<HeroBanner title="The Best Job Offers!" />
<SearchForm />
</>
);
}
Working types: 100% correct approach
- parse configuration
- extract types from services
- build a mapping of a service name to the correct type
We will have to generate types
in a build step
All components
& services are imported in configuration
Why is this a problem?
- client-side bundle will contain a code user will not need
- services for pages not visited
- even services for another websites
- bundle size will be too big
Split configuration to hostname-specific files
// /src/configuration/registry/prace
import HeroBanner from '@prace/ui/HeroBanner';
import jobOfferRepository from '@search/jobs/repository/jobOfferRepository';
const registry = {
components: {
HeroBanner: HeroBanner,
},
services: {
jobOfferRepository: jobOfferRepository,
},
parameters: {
searchEngine: 'algolia',
},
};
export default registry;
But what about resolvers?
// /src/configuration/resolver
export async function resolveComponent(hostname: string, componentName: string) {
const registryModule = await import(`./registry/${hostname}`);
const registry = registryModule.default;
const { components } = registry;
if (components?.[componentName]) {
return components[componentName];
}
return () => null;
}
We can dynamically import only the required one
- ✅ one deployed instance can serve multiple domains
But what about resolvers?
// /src/configuration/resolver
import registry from '@local/configuration/registry'
export function resolveComponent(componentName: string) {
const { components } = registry;
if (components?.[componentName]) {
return components[componentName];
}
return () => null;
}
Or we can build only one registry during the deployment
and it will be tied to the deployed hostname-instance
- ✅ simpler programming
- ❌ one deployed instance can serve only one domain
We will have to generate registers
in a build step
The registry is still too large, can we ... maybe ... lazy load some components and services?
Lazy loading of services
// /src/configuration/registry/prace
import HeroBanner from '@prace/ui/HeroBanner';
import jobOfferRepository from '@search/jobs/repository/jobOfferRepository';
const registry = {
components: {
HeroBanner: HeroBanner,
SearchForm: '@local/ui/SearchForm',
},
services: {
jobOfferRepository: jobOfferRepository,
},
parameters: {
searchEngine: 'algolia',
},
};
export default registry;
Lazy loading of services
// /src/configuration/resolver
import { ComponentType } from 'react';
import dynamic from 'next/dynamic'
import registry from '@local/configuration/registry'
function DefaultSkeleton() {
return null;
//return <p>Loading...</p>;
}
export function resolveComponent(
componentName: string,
skeleton: ComponentType = DefaultSkeleton
) {
const { components } = registry;
if (components?.[componentName]) {
if (typeof components[componentName] === 'string') {
return dynamic(() => import(components[componentName]), {
loading: skeleton,
});
}
return components[componentName];
}
return () => null;
}
Lazy loading of services
return dynamic(() => import(components[componentName]), {
loading: skeleton,
});
We can not use only variables in dynamic imports.
We need to help bundler to know what files can be loaded
But they can be placed anywhere...
Lazy loading of services
return dynamic(
() => {
return import(`@local/ui/agreed-place-1/${componentName}`)
.catch(
() => import(`@search/ui/agreed-place-2/${componentName}`)
);
}, {
loading: skeleton,
}
);
We can either restrict programmers and tell them they need to place their components in a few agreed locations
Lazy loading of services
// /src/configuration/resolver
import { ComponentType } from 'react';
import dynamic from 'next/dynamic'
import registry from '@local/configuration/registry'
function DefaultSkeleton() {
return null;
//return <p>Loading...</p>;
}
export function resolveComponent(
componentName: string,
skeleton: ComponentType = DefaultSkeleton
) {
const { components } = registry;
if (componentName === 'SearchForm') {
return dynamic(() => import('@local/ui/SearchForm'), {
loading: skeleton,
});
}
return components[componentName] ?? () => null;
}
Or we will have to generate resolvers
in a build step
- types
- registers
- resolvers
What do we need to generate?
To be able to do so, we need some form of
"recipe"
configuration
"recipe"
Configuration in JSON files
// /src/configuration/registry/prace
import HeroBanner from '@prace/ui/HeroBanner';
import jobOfferRepository from '@search/jobs/repository/jobOfferRepository';
const registry = {
components: {
HeroBanner: HeroBanner,
SearchForm: '@local/ui/SearchForm',
},
services: {
jobOfferRepository: jobOfferRepository,
},
parameters: {
searchEngine: 'algolia',
},
};
export default registry;
Configuration in JSON files
// /src/config/prace/config.json
{
"components": {
"HeroBanner": "@prace/ui/HeroBanner",
"SearchForm": [
"@local/ui/SearchForm",
{
"isLazy": true
}
]
},
"services": {
"jobOfferRepository": "@search/jobs/repository/jobOfferRepository"
},
"parameters": {
"searchEngine": "algolia"
}
}
Configuration in JSON files
- Configuration for one domain can be combined from default configuration file and configuration file with overrides for that domain
- We can even split configuration into multiple files and have one folder for "default" configuration and another folder for "domain" configuration.
React Server Components
can not be used inside of client components
React Server Components
- even their import in registry will break the build of our application
- this is actually a good thing
- we need to provide two registries
- one for server part
- with all services
- one for client part
- with only client-side services
- one for server part
React Server Components
// /src/config/prace/config.json
{
"components": {
"HeroBanner": [
"@prace/ui/HeroBanner",
{
"isServerSide": true,
}
],
"SearchForm": [
"@local/ui/SearchForm",
{
"isLazy": true
}
]
},
"services": {
"jobOfferRepository": "@search/jobs/repository/jobOfferRepository"
},
"parameters": {
"searchEngine": "algolia"
}
}
React Server Components
// /src/config/prace/config.json
{
"components": {
"HeroBanner": [
"@prace/ui/HeroBanner",
{
"isServerSide": true,
}
],
"SearchForm": [
"@local/ui/SearchForm",
{
"isLazy": true
}
]
},
"services": {
"jobOfferRepository": "@search/jobs/repository/jobOfferRepository"
},
"parameters": {
"searchEngine": "algolia"
}
}
How to use resolver from application and also from libraries?
App with register & resolver
Library
App with register
Library
Resolver
Client components
// libs/search/src/ui/Listing/Item.tsx
'use client'
import { useContext } from 'react';
import ServiceContainerContext from '@container/infrastructure/react/ServiceContainerContext';
export default function Item() {
const { resolveComponent } = useContext(ServiceContainerContext);
const OfferLabel = resolveComponent('Listing/OfferLabel');
return (
<>
{/*...*/}
<OfferLabel />
{/*...*/}
</>
);
}
Server components
// libs/search/src/ui/Listing/Item.tsx
import container from '@container/container/infrastructure/server';
export default function Item() {
const OfferLabel = container.resolveComponent('Listing/OfferLabel');
return (
<>
{/*...*/}
<OfferLabel />
{/*...*/}
</>
);
}
Server components
// libs/container/src/container/infrastructure/server.ts
import 'server-only';
import createContainer from '@local/container/application/createContainer';
import DependencyInjectionContainer, {
DependencyInjectionContainerProxy
} from '@local/container/application/types';
export function createProxyContainer(): DependencyInjectionContainerProxy {
let wrappedContainer: DependencyInjectionContainer = createContainer();
return {
setService(serviceName, service) {
wrappedContainer.setService(serviceName, service);
return this;
},
getService(serviceName) {
return wrappedContainer.getService(serviceName);
},
wrapContainer(container) {
wrappedContainer = container;
return this;
},
};
}
const container: DependencyInjectionContainerProxy = createProxyContainer();
export default container;
Problem with this solution:
- proxy container is effectively a singleton
- singletons are problem on a JS server, because of it single-threaded nature
- while we are processing one request, another one can come and replace the real container inside a proxy
- since memory is shared, it will be replaced for all currently running requests
- it will be a disaster if application would serve multiple domains
How to override components: Detailed version
By Milan Herda
How to override components: Detailed version
- 103