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
Or how Lodash lets you do:
import {debounce} from 'lodash';
import debounce from 'lodash/debounce'; // Also works
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';
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.
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} />;
}
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} />;
}
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} />;
}
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>
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;
};
// index.tsx
import {AccordionSummary} from './summary';
import {AccordionDetails} from './details';
export const Accordion: AccordionComponentType = (props) => {
return <MuiAccordion {...props} />;
};
Accordion.Summary = AccordionSummary;
Accordion.Details = AccordionDetails;
Usage:
import {Accordion} from '@company/ui-components';
<Accordion>
<Accordion.Summary>Title</Accordion.Summary>
<Accordion.Details>Content</Accordion.Details>
</Accordion>
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;
};
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} />;
}
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';
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/
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.
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';
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"
}
This tells bundlers where to find the code for each import path. Now when someone imports:
import {Button} from '@company/ui-components/buttons';
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));
}
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);
}
// 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}
/>
);
}
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}
/>
);
}
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}
/>
);
}
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} />;
}
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
}
✅ 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>;
}
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} />;
}
// Normal case
<Button variant="primary">Submit</Button>
// Edge case with library-specific feature
<Button variant="primary" muiProps={{startIcon: <Icon/>}}>Submit</Button>
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;
};
};
}
}
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>
</>
);
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"
}
}
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
// 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';
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';
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'
}]
}
]
}
};
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();
};
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'
}]
}
]
}
};
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';
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';
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
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
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)