Skip to main content
Writing

Building Framework-Agnostic Components

Explore how to design UI components that work seamlessly across different frameworks using dependency injection and composition patterns.
Georges Dugué
ReactJSTypeScriptArchitectureDesign Systems

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:

tsx
// ❌ 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:

tsx
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:

tsx
// 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:

tsx
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:

tsx
// ❌ 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:

tsx
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

  1. True Reusability: Use the same components across multiple projects
  2. Future-Proof: Switch frameworks without rewriting components
  3. Better Testing: Test components in isolation without framework overhead
  4. Cleaner Code: Separation of concerns between UI logic and framework integration
  5. 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.

Get in Touch

Protected by reCAPTCHA. Privacy & Terms.