DEV Community

Nazar Usik
Nazar Usik

Posted on

Wrapping Component Libraries

How production libraries handle decoupling, and what I learned building my own wrapper layer

The Pattern Everyone Uses (But I Didn't Understand)

Ever notice how you can import Material UI components like this?

import {Button} from '@mui/material';
import {Button} from '@mui/material/Button';  // Also works
Enter fullscreen mode Exit fullscreen mode

Or how Lodash lets you do:

import {debounce} from 'lodash';
import debounce from 'lodash/debounce';  // Also works
Enter fullscreen mode Exit fullscreen mode

I used these libraries for years without thinking about how they worked internally. Then I started building components for the embed examples, importing Material UI directly everywhere:

import {Button, TextField, Select} from '@mui/material';
Enter fullscreen mode Exit fullscreen mode

Worked fine until Material UI released a major version update. Suddenly I'm reading migration guides, updating dozens of files, mapping old prop names to new ones.

That's when I realized: I'd coupled my code directly to Material UI's API. Any change they made rippled through my entire codebase.

And that's when I started looking at how production libraries structure themselves. Turns out, they all use the same pattern: wrapper layers.

Component Wrapper Architecture

The Wrapper Pattern

The concept is simple: add an abstraction layer between your code and the UI library.

// Your application imports from your wrapper
import {Button} from '@company/ui-components';

// Wrapper delegates to whatever library you're using
import {Button as MuiButton} from '@mui/material';

export function Button(props) {
    return <MuiButton {...props} />;
}
Enter fullscreen mode Exit fullscreen mode

Application code never imports Material UI directly. Want to swap libraries? Update the wrapper. Application code stays unchanged.

This is how pretty much every major component library works internally. They wrap their own primitives. But I'd never built one myself, so I didn't understand the TypeScript patterns, the build setup, or the trade-offs.

Time to figure it out.

Starting Simple: Wrapping Button

I started with Button. Seemed simple enough – just wrap Material UI's button, right?

First attempt:

// First attempt - just pass everything through
import {Button as MuiButton, ButtonProps as MuiButtonProps} from '@mui/material';

export interface ButtonProps extends MuiButtonProps {
}

export function Button(props: ButtonProps) {
    return <MuiButton {...props} />;
}
Enter fullscreen mode Exit fullscreen mode

This worked, but it wasn't really abstracting anything. I was just passing through Material UI's entire API. If I switched libraries later, I'd still need to change application code.

Looking at how libraries like Radix UI handle this, I found an alternative approach: define your own prop interface, map it to the underlying library:

import {ButtonProps as MuiButtonProps} from '@mui/material';

// My API - omit MUI-specific props I'm replacing
interface ButtonProps extends Omit<MuiButtonProps, 'variant' | 'color'> {
    variant?: 'primary' | 'secondary' | 'danger';  // My variants, not MUI's
    onClick?: () => void;
    disabled?: boolean;
    children: React.ReactNode;
}

export function Button({variant, ...props}: ButtonProps) {
    // Map my variants to MUI's variants
    const muiVariant = variant === 'danger' ? 'contained' : 'contained';
    const muiColor = variant === 'danger' ? 'error' : variant;

    return <MuiButton variant={muiVariant} color={muiColor} {...props} />;
}
Enter fullscreen mode Exit fullscreen mode

Now the API is independent. Material UI's specifics stay hidden. Switch to a different library? Just update the mapping.

Compound Components (How Libraries Handle Multi-Part Components)

Button was straightforward. Accordion was trickier.

Material UI's Accordion has three parts that users import separately:

import {Accordion, AccordionSummary, AccordionDetails} from '@mui/material';

<Accordion>
    <AccordionSummary>Title</AccordionSummary>
    <AccordionDetails>Content</AccordionDetails>
</Accordion>
Enter fullscreen mode Exit fullscreen mode

How should I wrap this? Three separate exports felt clunky.

Then I looked at how Radix UI structures their components - they use static properties:

// types.ts
import {AccordionProps as MuiAccordionProps} from '@mui/material';
import {ReactElement} from 'react';

export interface AccordionProps extends MuiAccordionProps {
    // Your custom props
}

export type AccordionComponent = (props: AccordionProps) => ReactElement;
export type AccordionComponentType = AccordionComponent & {
    Summary: typeof AccordionSummary;
    Details: typeof AccordionDetails;
};
Enter fullscreen mode Exit fullscreen mode
// index.tsx
import {AccordionSummary} from './summary';
import {AccordionDetails} from './details';

export const Accordion: AccordionComponentType = (props) => {
    return <MuiAccordion {...props} />;
};

Accordion.Summary = AccordionSummary;
Accordion.Details = AccordionDetails;
Enter fullscreen mode Exit fullscreen mode

Usage:

import {Accordion} from '@company/ui-components';

<Accordion>
    <Accordion.Summary>Title</Accordion.Summary>
    <Accordion.Details>Content</Accordion.Details>
</Accordion>
Enter fullscreen mode Exit fullscreen mode

One import. Clear parent-child relationship. TypeScript autocomplete shows .Summary and .Details automatically.

This pattern works great for multi-part components. The TypeScript types are interesting:

export type AccordionComponent = (props: AccordionProps) => ReactElement;
export type AccordionComponentType = AccordionComponent & {
    Summary: typeof AccordionSummary;
    Details: typeof AccordionDetails;
};
Enter fullscreen mode Exit fullscreen mode

That intersection type (&) tells TypeScript that Accordion is both a function component and an object with properties. Same pattern Material UI and Radix use internally.

Theme Extension (Declaration Merging)

For the runtime config system from Article 1 to work, wrapper components need to read custom theme properties.

Material UI uses TypeScript's declaration merging for this:

// Theme declaration merging
declare module '@mui/material/styles' {
    interface Theme {
        custom: {
            button: {
                borderRadius: string;
                fontWeight: number;
            };
        };
    }
}

// Usage in component
import {useTheme} from '@mui/material';

export function Button(props: ButtonProps) {
    const theme = useTheme();
    // theme.custom.button.borderRadius is fully typed
    return <MuiButton sx={{borderRadius: theme.custom.button.borderRadius}} {...props} />;
}
Enter fullscreen mode Exit fullscreen mode

With this in place, TypeScript knows about theme.custom.button.borderRadius everywhere. No any types, no casting, full autocomplete.

Multi-Level Imports (The Build Setup Everyone Uses)

You've probably used both of these import styles:

// Full import
import {Button, TextField} from '@mui/material';

// Targeted import
import {Button} from '@mui/material/Button';
Enter fullscreen mode Exit fullscreen mode

Both work, but the second is better for tree-shaking. The bundler only includes what you import.

To enable both styles, you need index.ts files at every level:

src/
├── index.ts              # Root: export * from './buttons'; export * from './dataDisplay';
├── buttons/
│   ├── index.ts          # export * from './button'; export * from './group';
│   ├── button/
│   │   └── index.tsx     # export { Button }
│   └── group/
│       └── index.tsx
├── dataDisplay/
│   ├── index.ts          # export * from './accordion'; export * from './inputs';
│   ├── accordion/
│   │   └── index.tsx
│   └── inputs/
│       ├── index.ts      # export * from './textField'; export * from './checkbox';
│       ├── textField/
│       └── checkbox/
Enter fullscreen mode Exit fullscreen mode

This enables flexible import paths:

// Import from root (full library)
import {Button, TextField, Accordion} from '@company/ui-components';

// Import from category (targeted)
import {Button} from '@company/ui-components/buttons';
import {TextField} from '@company/ui-components/dataDisplay/inputs';

// Both work! Developer's choice.
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Tree-shaking friendly: Bundlers can eliminate unused code
  • Clear organization: Categories match your mental model
  • Faster imports: Import only what you need
  • Better intellisense: Category-based autocomplete

Implementation:

// src/index.ts (root)
export * from './buttons';
export * from './dataDisplay';
export * from './feedback';
export * from './layout';

// src/buttons/index.ts
export * from './button';
export * from './group';

// src/dataDisplay/inputs/index.ts
export * from './textField';
export * from './checkbox';
export * from './date';
Enter fullscreen mode Exit fullscreen mode

Every folder re-exports its children. Simple enough.

But there's a trick: this alone doesn't work. When you import @company/ui-components/buttons, npm looks for
node_modules/@company/ui-components/buttons/package.json.

That's why the build process needs to create individual package.json files for each subdirectory.

The Build Process (How Material UI Does It)

During build, create a package.json in each subfolder:

// Build script creates package.json in each folder
// lib/buttons/button/package.json
{
  "sideEffects": false,
  "module": "../../esm/buttons/button/index.js",
  "main": "../../cjs/buttons/button/index.js",
  "types": "../../types/src/buttons/button/index.d.ts"
}
Enter fullscreen mode Exit fullscreen mode

This tells bundlers where to find the code for each import path. Now when someone imports:

import {Button} from '@company/ui-components/buttons';
Enter fullscreen mode Exit fullscreen mode

The bundler reads lib/buttons/package.json, which points to the actual compiled code. This enables:

  • Tree-shaking: Only includes imported code
  • Targeted imports: Import from any depth
  • Proper module resolution: Works with ESM, CJS, and TypeScript

Build script example:

// Finds all folders with index.ts and creates package.json for each
const directoryPackages = glob.sync('*/index.{js,ts,tsx}', {cwd: 'src/buttons'})
    .map(path.dirname);

for (const dir of directoryPackages) {
    const packageJson = {
        sideEffects: false,
        module: `../../esm/buttons/${dir}/index.js`,
        main: `../../cjs/buttons/${dir}/index.js`,
        types: `../../types/src/buttons/${dir}/index.d.ts`
    };

    writeFileSync(`lib/buttons/${dir}/package.json`, JSON.stringify(packageJson));
}
Enter fullscreen mode Exit fullscreen mode

Look in node_modules/@mui/material/ and you'll find hundreds of these little package.json files. That's the trick.

My build script (scripts/setup-package.mjs) does the same: crawls the src/ folder and generates these files after TypeScript compilation.

Styling Approaches

For styling wrapper components, there are a few common approaches.

I used styled-components initially, but CSS-in-JS libraries are now in maintenance mode. The React team recommends other approaches:

Modern approaches:

Option 1: CSS Modules
Button.module.css

.button {
  border-radius: 8px;
  font-weight: 500;
  text-transform: none;
}

.primary {
  background-color: var(--color-primary);
}
Enter fullscreen mode Exit fullscreen mode
// Button.tsx
import styles from './Button.module.css';
import {Button as MuiButton} from '@mui/material';

export function Button({variant, ...props}: ButtonProps) {
    return (
        <MuiButton
            className={`${styles.button} ${variant === 'primary' ? styles.primary : ''}`}
            {...props}
        />
    );
}
Enter fullscreen mode Exit fullscreen mode

Option 2: Tailwind CSS

import {Button as MuiButton} from '@mui/material';
import clsx from 'clsx';

export function Button({variant, ...props}: ButtonProps) {
    return (
        <MuiButton
            className={clsx(
                'rounded-lg font-medium normal-case',
                variant === 'primary' && 'bg-primary text-white',
                variant === 'secondary' && 'border border-secondary'
            )}
            {...props}
        />
    );
}
Enter fullscreen mode Exit fullscreen mode

Option 3: Material UI's sx prop (if staying with MUI)

export function Button({variant, ...props}: ButtonProps) {
    return (
        <MuiButton
            sx={{
                borderRadius: 2,
                fontWeight: 500,
                textTransform: 'none',
                ...(variant === 'primary' && {bgcolor: 'primary.main'})
            }}
            {...props}
        />
    );
}
Enter fullscreen mode Exit fullscreen mode

Current recommendation: CSS Modules for full control and performance, Tailwind for rapid development, sx prop if staying with Material UI.

Integrating with Config

Wrapper components can read from the config system (Article 1):

import {useTheme} from '@/providers/theme';

export function Button(props: ButtonProps) {
    const theme = useTheme();
    // theme.components.button comes from runtime config
    const borderRadius = theme.components?.button?.borderRadius || '8px';

    return <MuiButton sx={{borderRadius}} {...props} />;
}
Enter fullscreen mode Exit fullscreen mode

Change the config URL, every button updates its styling. No rebuild, no deployment.

Common Patterns and Practices

Start with High-Use Components

Don't wrap everything at once. Start with what you actually use:

High priority:

  • Button
  • TextField
  • Select
  • Checkbox
  • Radio

Medium priority:

  • Dialog
  • Card
  • Tabs
  • Table

Low priority:

  • Specialized components
  • Rarely used components

Focus on core components first. Add others as needed.

Opinionated APIs with Escape Hatches

The pattern most libraries use: simple API for common cases, escape hatch for edge cases.

❌ Bad: Expose everything

interface ButtonProps extends MuiButtonProps {
    // Just passes through everything
}
Enter fullscreen mode Exit fullscreen mode

✅ Good: Opinionated API

interface ButtonProps {
    variant?: 'primary' | 'secondary' | 'text';
    size?: 'small' | 'medium' | 'large';
    disabled?: boolean;
    children: React.ReactNode;
    onClick?: () => void;
    // Escape hatch for advanced cases
    muiProps?: Partial<MuiButtonProps>;
}
Enter fullscreen mode Exit fullscreen mode

Most uses are simple. But occasionally you need library-specific features. The escape hatch pattern handles this:

interface ButtonProps {
    variant?: 'primary' | 'secondary';
    muiProps?: Partial<MuiButtonProps>;  // Escape hatch
}

export function Button({muiProps, ...props}: ButtonProps) {
    return <MuiButton {...props} {...muiProps} />;
}
Enter fullscreen mode Exit fullscreen mode
// Normal case
<Button variant="primary">Submit</Button>

// Edge case with library-specific feature
<Button variant="primary" muiProps={{startIcon: <Icon/>}}>Submit</Button>
Enter fullscreen mode Exit fullscreen mode

Type Safety with Declaration Merging

Extend theme types for full type safety:

// typings/theme.d.ts
import '@mui/material/styles';

declare module '@mui/material/styles' {
    interface Theme {
        custom: {
            button: {
                borderRadius: string;
                fontWeight: number;
            };
        };
    }

    interface ThemeOptions {
        custom?: {
            button?: {
                borderRadius?: string;
                fontWeight?: number;
            };
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Declaration merging gives full type safety for custom theme properties. theme.custom.button.borderRadius has autocomplete everywhere.

Documentation (Storybook)

Visual documentation helps:

// Button.stories.tsx
import {Button} from './Button';

export default {
    title: 'Components/Button',
    component: Button,
};

export const Primary = () => <Button variant="primary">Primary</Button>;
export const Secondary = () => <Button variant="secondary">Secondary</Button>;
export const Sizes = () => (
    <>
        <Button size="small">Small</Button>
        <Button size="medium">Medium</Button>
        <Button size="large">Large</Button>
    </>
);
Enter fullscreen mode Exit fullscreen mode

Developers can see all variants, copy examples, understand usage without reading type definitions.

Versioning

If multiple projects depend on your wrapper, use semantic versioning:

{
  "name": "@company/ui-components",
  "version": "2.1.0",
  "peerDependencies": {
    "@mui/material": "^5.0.0",
    "react": "^18.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Teams can upgrade on their own schedule. Breaking changes go in major versions.

Migration Strategy

If you're adding wrappers to an existing codebase, gradual migration works better than big-bang rewrites.

Phase 1: Build the Wrapper Separately

Create the wrapper as a separate package while existing code continues using the library directly:

# Create package
mkdir ui-components
cd ui-components
npm init

# Structure
ui-components/
├── src/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx
│   │   └── types.ts
│   ├── TextField/
│   ├── Select/
│   └── index.ts
├── package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode
// src/Button/Button.tsx
export function Button(props: ButtonProps) {
    return <MuiButton {...props} />;
}

// src/index.ts
export {Button} from './Button/Button';
export {TextField} from './TextField/TextField';
export {Select} from './Select/Select';
Enter fullscreen mode Exit fullscreen mode

Phase 2: Allow Both Import Styles

Let existing code continue using the library directly:

// Old way (still works)
import {Button} from '@mui/material';

// New way (preferred)
import {Button} from '@company/ui-components';
Enter fullscreen mode Exit fullscreen mode

Add an ESLint warning to encourage wrapper usage:

// .eslintrc.js
module.exports = {
    rules: {
        'no-restricted-imports': [
            'warn',
            {
                paths: [{
                    name: '@mui/material',
                    message: 'Please use @company/ui-components instead'
                }]
            }
        ]
    }
};
Enter fullscreen mode Exit fullscreen mode

New code uses the wrapper. Old code can stay on direct imports.

Phase 3: Automate with Codemod (Optional)

For large codebases, use jscodeshift to automate the migration:

// transform.js
module.exports = function (file, api) {
    const j = api.jscodeshift;
    const root = j(file.source);

    // Find Material UI imports
    root
        .find(j.ImportDeclaration, {
            source: {value: '@mui/material'}
        })
        .forEach(path => {
            // Replace with wrapper imports
            j(path).replaceWith(
                j.importDeclaration(
                    path.value.specifiers,
                    j.literal('@company/ui-components')
                )
            );
        });

    return root.toSource();
};

Enter fullscreen mode Exit fullscreen mode

Handles most imports automatically. Some edge cases need manual fixes.

Phase 4: Enforce (Eventually)

Once most code uses the wrapper, change ESLint from 'warn' to 'error':

// .eslintrc.js
module.exports = {
    rules: {
        'no-restricted-imports': [
            'error',  // Changed from 'warn' to 'error'
            {
                paths: [{
                    name: '@mui/material',
                    message: 'Direct Material UI imports not allowed. Use @company/ui-components'
                }]
            }
        ]
    }
};
Enter fullscreen mode Exit fullscreen mode

Integration with Other Patterns

With Embed Server

For the self-mounting embeds from Article 0, a shared wrapper library keeps everything consistent:

// All embeds import from the same wrapper library
import {Button, TextField} from '@company/ui-components';
Enter fullscreen mode Exit fullscreen mode

All embeds use the same wrapper. Update once, all embeds get the changes.

With Multi-Layer Config

The wrapper components read from the config system (Article 1):

const theme = useTheme();
const borderRadius = theme.components?.button?.borderRadius || '8px';
Enter fullscreen mode Exit fullscreen mode

Buttons read from config. Change the config URL, styling updates. No rebuild needed.

With Declarative Forms

The form system from Article 2 uses wrapped components:

import {TextField, Checkbox, Radio} from '@company/ui-components';
// All form inputs automatically consistent
Enter fullscreen mode Exit fullscreen mode

All form inputs use the same wrappers, reading from the same config. Consistency is automatic.

Trade-offs

What You Gain

Library independence: Switch from Material UI to Ant Design by changing one package. Application code unchanged.

Centralized styling: Button styling in one file. Change once, affects everywhere.

Enhanced functionality: Add analytics, accessibility, loading states in one place. Every usage gets the enhancement automatically.

Consistent API: Teams use the same button API. No variations, no copy-paste styling.

Easier testing: Mock your wrapper, not Material UI. Simpler test setup.

These benefits are significant for multi-application scenarios.

What It Costs

Initial development: Building the wrapper layer takes time. Each component needs wrapping.

Maintenance overhead: Material UI updates require wrapper updates. New features need exposure through wrapper API.

Learning curve: Team needs to learn wrapper API, not just Material UI.

Abstraction leakage: Complex components harder to wrap completely. May need escape hatches to underlying library.

Bundle size: Additional wrapper code. May include unused library features.

When It's Worth It

The pattern makes sense when:

  • Multiple applications share components
  • Long-term maintenance expected (multi-year projects)
  • Library migration likely in the future
  • Consistent branding required across teams
  • Runtime configuration needed (Article 1)

It's not worth it when:

  • Single small project with one developer
  • Tight deadline with no time for abstraction
  • Library deeply integrated (major refactor needed)
  • No future migration planned

Don't over-engineer. Build abstraction when it solves real problems.

Example Implementation

Full example at github.com/NazarUsik/AdaptUI.

Key areas to explore:

  • src/buttons/button/ - basic wrapper pattern
  • src/dataDisplay/accordion/ - compound components
  • src/providers/theme/ - config integration
  • scripts/setup-package.mjs - build process for multi-level imports

Basic structure:

src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx
│   │   ├── Button.stories.tsx
│   │   └── types.ts
│   ├── TextField/
│   ├── Select/
│   └── index.ts
├── providers/
│   ├── config/
│   └── theme/
└── index.ts
Enter fullscreen mode Exit fullscreen mode

Summary

Wrapper libraries are standard practice in production applications. Material UI, Radix, and most component libraries use this pattern internally.

The benefits:

  • Library independence
  • Centralized styling
  • Consistent APIs across applications

The costs:

  • Initial development time
  • Maintenance overhead
  • Learning curve for new patterns

For single-application projects, direct library imports work fine. For multi-application scenarios with shared components and runtime theming, wrapper layers make sense.

The key patterns:

  • Opinionated APIs with escape hatches
  • Compound components for multi-part elements
  • Multi-level imports for tree-shaking
  • Declaration merging for type safety
  • Build scripts for package.json generation

These patterns work together with:

  • Embed server (Article 0) for shared components
  • Multi-layer config (Article 1) for runtime theming
  • Declarative forms (Article 2) for consistent inputs

Start small. Wrap core components first. Add complexity as needed. Don't over-engineer.


Author: Nazar Usik

GitHub: AdaptUI

Related:

Top comments (0)