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 ‘danger‘; // My variants, not MUI’s
onClick?: () => void;
disabled?: boolean;
children: React.ReactNode;
from ‘@mui/material‘;
import ‘medium‘ from ‘@mui/material/Button‘; // Also works
Enter fullscreen mode
Exit fullscreen mode
Or how Lodash lets you do:
import ‘secondary‘;
muiProps?: PartialMuiButtonProps>; // Escape hatch
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
variant?: ‘primary‘ 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.
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 ‘secondary‘ from ‘@company/ui-components‘;
// Wrapper delegates to whatever library you’re using
import
variant?: ‘primary‘ from ‘@mui/material‘;
export function Button(props)
variant?: ‘primary‘
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 ‘secondary‘ from ‘@mui/material‘;
export interface ButtonProps extends MuiButtonProps
variant?: ‘primary‘
export function Button(props: ButtonProps) {
return MuiButton ‘secondary‘ />;
}
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
variant?: ‘primary‘ from ‘@mui/material‘;
// My API – omit MUI-specific props I’m replacing
interface ButtonProps extends OmitMuiButtonProps, ‘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>TitleAccordionSummary>
AccordionDetails>ContentAccordionDetails>
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>TitleAccordion.Summary>
Accordion.Details>ContentAccordion.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:
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?: PartialMuiButtonProps>;
}
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?: PartialMuiButtonProps>; // Escape hatch
}
export function Button({muiProps, …props}: ButtonProps) {
return MuiButton {…props} {…muiProps} />;
}
Enter fullscreen mode
Exit fullscreen mode
// Normal case
Button variant=“primary”>SubmitButton>
// Edge case with library-specific feature
Button variant=“primary” muiProps={{startIcon: Icon/>}}>SubmitButton>
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”>PrimaryButton>;
export const Secondary = () => Button variant=“secondary”>SecondaryButton>;
export const Sizes = () => (
Button size=“small”>SmallButton>
Button size=“medium”>MediumButton>
Button size=“large”>LargeButton>
>
);
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:
