Building reusable components is one of the core challenges of modern web development. But what if you could build components that work not just with one framework, but with any framework? This is the power of framework-agnostic component design.
The Problem with Framework Coupling
When we build components, it's easy to accidentally couple them to a specific framework:
// ❌ BAD - Tightly coupled to Next.js
import Link from "next/link";
import Image from "next/image";
export function ProductCard({ product }) {
return (
<div>
<Image src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<Link href={`/products/${product.id}`}>View Details</Link>
</div>
);
}This component is now permanently bound to Next.js. Reusing it in a React-based Gatsby site, Vite app, or Astro project would require significant refactoring.
The Framework-Agnostic Approach
Instead of directly importing framework-specific components, we can use dependency injection to make our components truly portable:
import React from "react";
interface ProductCardProps {
product: {
id: string;
name: string;
image: string;
};
LinkComponent?: React.ElementType;
ImageComponent?: React.ElementType;
}
export function ProductCard({
product,
LinkComponent = "a",
ImageComponent = "img",
}: ProductCardProps) {
const linkProps =
LinkComponent === "a" ? { href: `/products/${product.id}` } : { to: `/products/${product.id}` };
const imageProps =
ImageComponent === "img"
? { src: product.image, alt: product.name }
: { src: product.image, alt: product.name, fill: true };
return (
<div>
{React.createElement(ImageComponent, imageProps)}
<h3>{product.name}</h3>
{React.createElement(LinkComponent, {
...linkProps,
children: "View Details",
})}
</div>
);
}Now, any framework can use this component by providing the appropriate components:
// In Next.js
import Link from "next/link";
import Image from "next/image";
<ProductCard
product={product}
LinkComponent={Link}
ImageComponent={Image}
/>
// In Gatsby
import { Link as GatsbyLink } from "gatsby";
import { GatsbyImage } from "gatsby-plugin-image";
<ProductCard
product={product}
LinkComponent={GatsbyLink}
ImageComponent={GatsbyImage}
/>
// In vanilla React
<ProductCard product={product} />Three Core Patterns
1. Dependency Injection
Pass components as props to make your components framework-agnostic:
interface ButtonProps {
LinkComponent?: React.ElementType;
href?: string;
children: React.ReactNode;
}
export function Button({ LinkComponent = "button", href, children }: ButtonProps) {
if (href && LinkComponent !== "button") {
return React.createElement(LinkComponent, { href, children });
}
return <button>{children}</button>;
}2. Composition Over Framework Features
Use React's built-in features and composition rather than framework-specific APIs:
// ❌ BAD - Uses framework-specific API
export function Layout({ children }) {
return <Wrapper>{children}</Wrapper>;
}
// ✅ GOOD - Pure React composition
export function Layout({
children,
header: HeaderComponent = DefaultHeader,
footer: FooterComponent = DefaultFooter,
}: LayoutProps) {
return (
<div>
<HeaderComponent />
<main>{children}</main>
<FooterComponent />
</div>
);
}3. Configuration Over Convention
Instead of relying on framework conventions, make everything configurable:
interface AppConfig {
LinkComponent: React.ElementType;
ImageComponent: React.ElementType;
theme: ThemeConfig;
i18n: I18nConfig;
}
export const AppProvider = ({
config,
children,
}: {
config: AppConfig;
children: React.ReactNode;
}) => <AppContext.Provider value={config}>{children}</AppContext.Provider>;Benefits You'll Unlock
- True Reusability: Use the same components across multiple projects
- Future-Proof: Switch frameworks without rewriting components
- Better Testing: Test components in isolation without framework overhead
- Cleaner Code: Separation of concerns between UI logic and framework integration
- Team Flexibility: Components work with developers' framework of choice
When NOT to Be Framework-Agnostic
It's worth noting that framework-agnostic design isn't always necessary. For components that are:
- Tightly integrated with a specific framework's features
- Project-specific utilities that won't be reused
- Performance-critical components tied to framework optimization
- Internal components within a single application
...it's perfectly fine to use framework-specific patterns.
Conclusion
Framework-agnostic component design is a powerful architectural pattern that future-proofs your code and increases reusability. By using dependency injection, composition, and configuration patterns, you can build components that are truly portable across the modern JavaScript ecosystem.
The key is finding the right balance between abstraction and practicality for your specific needs. Start with clear boundaries between your design system layer and your implementation layer, and your components will naturally become more portable and maintainable.