A revolutionary approach to React component design: Composition over Configuration
Traditional React components for complex UI elements often fall into the "prop hell" trap:
// The old way: prop explosion
<DatePicker
showClearDates={true}
showDefaultInputIcon={true}
customInputIcon={<Icon />}
customArrowIcon={<Icon />}
customCloseIcon={<Icon />}
displayFormat="MM/DD/YYYY"
monthFormat="MMMM YYYY"
phrases={{
closeDatePicker: 'Close',
clearDates: 'Clear',
// ... 20+ more phrases
}}
renderCalendarInfo={() => <div>Info</div>}
renderDayContents={(day) => day.format('D')}
renderMonthElement={({ month }) => month}
// ... dozens more props
/>This approach has critical flaws:
- API Explosion: Every customization requires a new prop
- Maintenance Nightmare: Component logic becomes a maze of conditionals
- Poor Scalability: Adding features means adding props and complexity
- Limited Flexibility: Only what props allow, nothing more
- Testing Complexity: Combinatorial explosion of prop permutations
- Bundle Size: All features shipped to all users, always
This project demonstrates a fundamentally different approach: provide atomic building blocks, let consumers compose them.
Instead of accepting dozens of formatting props, the Money component:
- Decomposes money values into atomic fragments (operator, currency, symbol, number)
- Provides pre-rendered elements and component factories
- Delegates presentation to lightweight formatter components
- Maintains a minimal, stable API that never grows
The Money component does one thing exceptionally well: decomposition.
<Money currency="EUR" format={CustomFormatter}>1234.56</Money>Internally, it:
- Parses the value using
Intl.NumberFormat(locale-aware) - Extracts atomic fragments:
operator: "+" or "-"currency: "EUR"symbol: "€"number: "1,234.56"
- Creates bound component factories for each fragment type
- Generates pre-rendered React elements
- Passes everything to the formatter
Key insight: The Money component contains zero presentation logic. It's purely a data transformation pipeline.
Note
Historical Context: When this component was originally conceived, the Intl.NumberFormat.prototype.formatToParts() API didn't exist yet. The current implementation uses RegExp to parse the formatted string and extract fragments (symbol, number, etc.).
If this were to be maintained or published as a package, migrating to formatToParts() would be a natural improvement - it provides a much more ergonomic way to decompose formatted numbers without parsing:
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).formatToParts(1234.56)
// Returns: [
// { type: 'currency', value: '$' },
// { type: 'integer', value: '1,234' },
// { type: 'decimal', value: '.' },
// { type: 'fraction', value: '56' }
// ]However, the core architectural principle—composition over configuration—remains valuable regardless of the parsing implementation.
Formatters receive all the building blocks and simply arrange them:
// MoneyJustNumbersFormatter.js - 15 lines total
const MoneyJustNumbersFormatter = ({
components: { container: Container },
elements: { operator, number },
negative,
}) => {
const children = useMemo( // Memoized for performance
() => [negative && operator, number],
[negative, operator, number]
);
return <Container>{children}</Container>;
};That's it. No prop parsing, no conditionals, no complexity. Just composition.
// Component implementation: 300+ lines
// Props needed: 25+
// Conditional branches: 15+
<MoneyDisplay
value={1234.56}
currency="EUR"
locale="en-US"
showSymbol={true}
showCurrencyCode={true}
symbolPosition="before"
currencyPosition="after"
showOperator={false}
operatorPosition="before"
abbreviateLargeNumbers={false}
abbreviationThreshold={1000}
abbreviationUnits={['k', 'M', 'B']}
granularDigits={false}
colorCodeMagnitudes={false}
magnitudeColors={{ thousand: '#blue', million: '#green' }}
decimalPlaces={2}
thousandsSeparator=","
decimalSeparator="."
negativeFormat="minus"
// ... and more
/>// Six built-in formatters, each 15-40 lines
// Core component: 104 lines, zero presentation logic
// Props needed: 4 (value, currency, locale, format)
// Default format
<Money currency="EUR">1234.56</Money>
// Output: +€ EUR 1,234.56
// Just numbers
<Money currency="EUR" format={MoneyJustNumbersFormatter}>1234.56</Money>
// Output: +1,234.56
// Abbreviated
<Money currency="EUR" format={MoneyRoundedFormatter}>1234.56</Money>
// Output: +€ 1.2k
// Custom format (create your own in minutes)
const CustomFormatter = ({ elements: { symbol, number } }) => (
<span className="price">{symbol}{number}</span>
);
<Money currency="EUR" format={CustomFormatter}>1234.56</Money>
// Output: €1,234.56- MoneyDefaultFormatter - Symbol + currency code
- MoneyJustCurrencyFormatter - Currency code only
- MoneyJustSymbolFormatter - Symbol only
- MoneyJustNumbersFormatter - Numbers only
- MoneyRoundedFormatter - Abbreviated (1.2k, 3.5M)
- MoneyGranularElementsFormatter - Fine-grained per-digit styling (see advanced example below)
Each formatter is 15-88 lines. Each is independently testable. Each is tree-shakeable.
Adding a new format variant:
- Traditional approach: Add 3-5 props, add conditional logic, update tests, increase bundle size
- This approach: Create a 20-line formatter component
Want to color each digit differently based on magnitude? Want to animate the transition between values? Want to add tooltips to specific parts?
- Traditional approach: Request new props from maintainer, wait for release
- This approach: Write a custom formatter, takes 10 minutes
- Traditional approach: Core component grows with every feature request
- This approach: Core component never changes, formatters are isolated
- Traditional approach: All features shipped always, ~50kb+
- This approach: Import only what you need, ~5kb + chosen formatters
- Traditional approach: Test all prop combinations (exponential complexity)
- This approach: Test core component once, test each formatter in isolation
The MoneyGranularElementsFormatter demonstrates the true power of this compositional approach. It does something that would be nearly impossible with traditional props: style each digit group independently based on magnitude.
Want to:
- Make cents smaller and underlined?
- Color-code digits by magnitude (thousands gray, millions blue, billions yellow)?
- Add hover effects to specific digit groups?
- Animate transitions between magnitude levels?
- Add tooltips explaining each group?
Traditional approach: You'd need dozens of props like centsStyle, hundredsStyle, thousandsStyle, millionsStyle, centsClassName, thousandsClassName, onHundredsHover, etc. The combinatorial explosion makes this impractical.
This approach: One formatter component that semantically marks up the structure, then style with CSS.
The formatter decomposes the number into atomic fragments and tags each with semantic attributes:
<Money currency="USD" format={MoneyGranularElementsFormatter}>
1234567.89
</Money>Generates this HTML:
<span class="container">
<span class="symbol">$</span>
<span class="currency">USD</span>
<span class="number">
<span class="fragment" type="integer" subtype="million">1</span>
<span class="fragment" type="separator-integer">,</span>
<span class="fragment" type="integer" subtype="thousand">234</span>
<span class="fragment" type="separator-integer">,</span>
<span class="fragment" type="integer" subtype="hundred">567</span>
<span class="fragment" type="separator-decimal">.</span>
<span class="fragment" type="decimal">89</span>
</span>
</span>Each fragment receives:
type attribute:
decimal- The fractional part (cents)separator-decimal- The decimal pointinteger- Integer digit groupsseparator-integer- Thousands separators
subtype attribute (for integers):
hundred- 1-999thousand- 1,000-999,999million- 1,000,000-999,999,999billion- 1,000,000,000-999,999,999,999trillion- 1,000,000,000,000+
With this semantic structure, styling becomes trivial using CSS attribute selectors:
/* Make cents smaller and underlined */
.fragment[type="decimal"] {
font-size: 50%;
text-decoration: underline;
vertical-align: super;
}
/* Color-code by magnitude */
.fragment[subtype="hundred"] {
background-color: #f4f4f8;
color: #1f2d3d;
}
.fragment[subtype="thousand"] {
background-color: #e6e6ea;
color: #1f2d3d;
}
.fragment[subtype="million"] {
background-color: #009fb7;
color: white;
}
.fragment[subtype="billion"] {
background-color: #fed766;
color: #1f2d3d;
}
.fragment[subtype="trillion"] {
background-color: #fe4a49;
color: white;
}
/* Add hover effects */
.fragment[type="integer"]:hover {
transform: scale(1.1);
cursor: pointer;
}
/* Animate magnitude changes */
.fragment[type="integer"] {
transition: background-color 0.3s ease;
}$1,234,567.89 renders with:
- Millions (1) in blue background
- Thousands (234) in light gray background
- Hundreds (567) in lighter gray background
- Cents (89) smaller, underlined, superscripted
What traditional props would need:
<Money
value={1234567.89}
currency="USD"
// Styling props
centsStyle={{ fontSize: '50%', textDecoration: 'underline' }}
hundredsStyle={{ backgroundColor: '#f4f4f8' }}
thousandsStyle={{ backgroundColor: '#e6e6ea' }}
millionsStyle={{ backgroundColor: '#009fb7', color: 'white' }}
billionsStyle={{ backgroundColor: '#fed766' }}
trillionsStyle={{ backgroundColor: '#fe4a49', color: 'white' }}
// Event handlers
onCentsHover={handleHover}
onHundredsHover={handleHover}
onThousandsHover={handleHover}
// Animation props
enableMagnitudeAnimations={true}
animationDuration={300}
// ... and it gets worse
/>Problems:
- Component must handle all styling logic internally
- Can't use CSS pseudo-selectors (
:hover,:focus, etc.) - Can't use CSS transitions/animations
- Can't use media queries for responsive styling
- Can't leverage CSS cascade or inheritance
- Component bundle includes all this logic whether you use it or not
What this approach requires:
<Money currency="USD" format={MoneyGranularElementsFormatter}>
1234567.89
</Money>Plus a CSS file. That's it.
The entire formatter is ~88 lines:
const MoneyGranularElementsFormatter = ({
components: { container: Container, number: Number },
elements: { operator, currency, symbol },
fragments: { number },
negative,
reverse,
}) => {
// Split number by separators (. and ,)
const granular = useMemo(() => {
const fragments = number
.split(/([.,])+/gi)
.reverse()
.reduce((stack, fragment, index) => {
// Determine type and subtype based on position
const attrs = getTypeFrom({ fragment, index });
return [
<span key={index} className="fragment" {...attrs}>
{fragment}
</span>,
].concat(stack);
}, []);
return <Number>{fragments}</Number>;
}, [number]);
const children = useMemo(() => {
switch (true) {
case reverse:
return [negative && operator, granular, symbol, currency];
default:
return [negative && operator, symbol, currency, granular];
}
}, [reverse, negative, operator, currency, symbol, granular]);
return <Container>{children}</Container>;
};No styling logic. No prop parsing. Just semantic markup.
This pattern enables:
- Financial dashboards - Color-code by magnitude for quick scanning
- Accounting software - Highlight cents differently from dollars
- Data visualization - Animate value changes with magnitude transitions
- Accessibility - Add ARIA labels to each magnitude group
- Educational apps - Interactive tooltips explaining place values
- Mobile responsive - Hide certain digit groups on small screens via CSS
- Theming - Different color schemes per client via CSS variables
All without touching the component code.
By providing semantic structure instead of style props, formatters enable use cases the component author never imagined.
The component's job is to describe what things are (decimal, thousands, millions).
The consumer's job is to decide how things look.
Perfect separation of concerns.
One of the most powerful aspects of this compositional approach: effortless integration with any third-party library.
Since formatters are just functions that receive data and return JSX, you can plug in any library without modifying the core component.
The MoneyRoundedFormatter demonstrates this perfectly. It integrates numeral.js for number abbreviation in just 26 lines:
import numeral from 'numeral';
const MoneyRoundedFormatter = ({
components: { container: Container, number: Number },
elements: { operator, symbol },
value,
negative,
reverse,
}) => {
// Use numeral.js to abbreviate the number
const rounded = useMemo(() => numeral(value).format('0a'), [value]);
const number = useMemo(() => <Number>{rounded}</Number>, [rounded]);
const children = useMemo(() => {
switch (true) {
case reverse:
return [negative && operator, number, symbol];
default:
return [negative && operator, symbol, number];
}
}, [reverse, negative, operator, number, symbol]);
return <Container>{children}</Container>;
};Usage:
<Money currency="USD" format={MoneyRoundedFormatter}>1234567</Money>
// Output: $1.2m
<Money currency="EUR" format={MoneyRoundedFormatter}>8900</Money>
// Output: €8.9kThat's it. No need to add props to the core component. No need to update the API. Just import, use, done.
Traditional approach:
To add abbreviation support, you'd need to:
- Add the library as a dependency to the core component
- Add props:
abbreviate={true},abbreviationFormat="0a",abbreviationThreshold={1000} - Add conditional logic inside the component
- Increase bundle size for all users
- Create tight coupling between the component and the library
- Risk breaking changes when updating the library
This approach:
- Create a new formatter file
- Import the library
- Use it
The core component knows nothing about numeral.js. The core component never changes. Users who don't need abbreviation don't pay for it.
The same pattern works with any library:
Accounting.js Integration
import accounting from 'accounting';
const MoneyAccountingFormatter = ({ value, currency, /* ... */ }) => {
const formatted = useMemo(
() => accounting.formatMoney(value, { symbol: currency }),
[value, currency]
);
// ... compose with elements
};Dinero.js Integration
import Dinero from 'dinero.js';
const MoneyDineroFormatter = ({ value, currency, /* ... */ }) => {
const formatted = useMemo(
() => Dinero({ amount: value, currency }).toFormat(),
[value, currency]
);
// ... compose with elements
};const MoneyWithTaxFormatter = ({ value, elements, /* ... */ }) => {
const taxRate = useTaxRate(); // Custom hook
const withTax = useMemo(() => value * (1 + taxRate), [value, taxRate]);
return (
<Container>
{elements.symbol}
<span className="subtotal">{value}</span>
<span className="tax">+{(value * taxRate).toFixed(2)}</span>
<span className="total">{withTax.toFixed(2)}</span>
</Container>
);
};React Spring Animation Integration
import { animated, useSpring } from 'react-spring';
const MoneyAnimatedFormatter = ({ value, elements, /* ... */ }) => {
const props = useSpring({ number: value, from: { number: 0 } });
return (
<Container>
{elements.symbol}
<animated.span>
{props.number.to(n => n.toFixed(2))}
</animated.span>
</Container>
);
};Every integration follows the same simple pattern:
- Import the library in your formatter
- Use the library's API with the data you receive
- Compose the result with the provided elements/components
- Done - no core component changes needed
- Zero coupling: Core component independent of any library
- Bundle optimization: Tree-shake unused formatters and their dependencies
- Version flexibility: Each formatter can use different library versions
- Easy migration: Update or replace libraries without touching core code
- Mix and match: Combine multiple libraries in one formatter
- Future-proof: Integrate libraries that don't exist yet
| Aspect | Traditional Props Approach | Compositional Approach |
|---|---|---|
| Add new library | Modify core component | Create new formatter |
| Bundle impact | All users pay the cost | Only users of that formatter |
| Coupling | Tight coupling | Zero coupling |
| Version conflicts | Single version for all | Each formatter independent |
| Breaking changes | Affects all users | Isolated to formatter |
| Maintenance | Core component grows | Core component stable |
Imagine you need to support:
- Standard formatting (built-in Intl)
- Abbreviated numbers (numeral.js)
- Accounting format (accounting.js)
- Cryptocurrency (custom library)
- Animated transitions (react-spring)
Traditional approach:
// Core component dependencies
import numeral from 'numeral';
import accounting from 'accounting';
import cryptoFormat from 'crypto-format';
import { useSpring } from 'react-spring';
// Component with 50+ props and complex conditional logic
const Money = ({
value,
currency,
useAbbreviation,
abbreviationFormat,
useAccounting,
accountingFormat,
isCrypto,
cryptoOptions,
animated,
animationDuration,
// ... 40+ more props
}) => {
// 200+ lines of conditional logic
};Bundle size: ~150kb (all libraries shipped to everyone)
This approach:
// Core component - no external dependencies
const Money = ({ value, currency, format, children }) => {
// 104 lines - pure data transformation
};
// Formatters in separate files
// MoneyRoundedFormatter.js - uses numeral.js
// MoneyAccountingFormatter.js - uses accounting.js
// MoneyCryptoFormatter.js - uses crypto-format
// MoneyAnimatedFormatter.js - uses react-springBundle size: ~5kb + chosen formatters (tree-shaken)
By making formatters composable functions instead of configurable components, you get:
- Unlimited extensibility without API growth
- Pay-per-use bundle size via tree-shaking
- Library ecosystem compatibility out of the box
- Future-proof architecture for libraries that don't exist yet
You're not building a component. You're building a platform for money formatting.
Every computation is memoized with useMemo:
const fragments = useMemo(() => {
// Parse once, reuse until currency/locale/value changes
}, [currency, locale, value]);Built on Intl.NumberFormat - handles all locale formatting automatically:
<Money locale="pt-BR" currency="BRL">1234.56</Money>
// Output: +R$ BRL 1.234,56 (locale-aware separators)PropTypes with ISO 4217 currency code validation:
Money.propTypes = {
currency: oneOf(ISOCurrency.codes()).isRequired, // Validates against ISO 4217
// ...
};Creates bound component functions for each fragment type using higher-order functions and useCallback:
const getBoundComponentWith = useCallback(
defaultProps => props =>
createElement("span", { ...defaultProps, ...extraProps, ...props }),
[extraProps]
);This enables formatters to render fragments with custom props while maintaining defaults.
This project demonstrates mastery of:
- React Composition Patterns - Render props, component factories, element composition
- API Design - Minimal surface area, maximum flexibility
- Separation of Concerns - Data transformation vs. presentation logic
- Performance - Aggressive memoization, tree-shaking
- Internationalization - Locale-aware formatting with Intl API
- Extensibility - Open/closed principle in practice
- Third-Party Integration - Seamless integration with any library ecosystem
- Problem Recognition - Identifying and solving architectural anti-patterns
- Technical Evolution - Awareness of modern APIs and continuous improvement opportunities
After wrestling with airbnb/react-dates and its 200+ props, I realized: we're solving the wrong problem.
The question isn't "how do we expose all customization through props?"
The question is "how do we give developers the primitives to build exactly what they need?"
This component is the answer.
yarn install
yarn startOpen http://localhost:3000 to see all formatters in action.
This project includes comprehensive test coverage with both unit and integration tests.
# Run unit tests (React Testing Library)
yarn test
# Run unit tests with coverage
yarn test:unit
# Run e2e tests (Playwright)
yarn test:e2e
# Run all tests
yarn test:allTest Coverage:
- 70+ test cases
- Unit tests for core component and all formatters
- Integration tests for visual rendering and cross-browser compatibility
- See TESTING.md for detailed testing documentation
src/
├── components/
│ └── Money/
│ ├── Money.js # Core component (104 lines)
│ ├── Money.style.js # Styled-components wrapper
│ └── Formatters/
│ ├── MoneyDefaultFormatter/
│ ├── MoneyJustCurrencyFormatter/
│ ├── MoneyJustSymbolFormatter/
│ ├── MoneyJustNumbersFormatter/
│ ├── MoneyRoundedFormatter/
│ └── MoneyGranularElementsFormatter/
- React 16.9+ (Hooks)
- styled-components
- Intl.NumberFormat API
- currency-codes (ISO 4217 validation)
- numeral (number abbreviation)
- prop-types (Runtime type checking)
Formatters receive this shape:
{
locale: string, // e.g., "en-US"
negative: boolean, // Is value negative?
value: number, // Absolute value
formatted: string, // Full formatted string from Intl
reverse: boolean, // Does symbol come after number?
fragments: { // Raw string values
operator: string, // "+" or "-"
currency: string, // "EUR"
symbol: string, // "€"
number: string, // "1,234.56"
},
classNames: { // CSS class names
container: string,
operator: string,
currency: string,
symbol: string,
number: string,
},
components: { // Bound component factories
container: Component,
operator: Component,
currency: Component,
symbol: Component,
number: Component,
},
elements: { // Pre-rendered React elements
operator: ReactElement,
currency: ReactElement,
symbol: ReactElement,
number: ReactElement,
}
}Example custom formatter:
const MoneyBoldSymbol = ({
components: { container: Container },
elements: { symbol, number },
negative,
reverse
}) => (
<Container>
{!reverse && <strong>{symbol}</strong>}
{negative && "-"}
{number}
{reverse && <strong>{symbol}</strong>}
</Container>
);This isn't just a money formatting component. It's a demonstration of architectural thinking:
- Recognizing anti-patterns (prop explosion)
- Applying React's core philosophy (composition)
- Designing for the pit of success (easy to extend, hard to break)
- Prioritizing developer experience (simple, powerful, predictable)
- Understanding when to improve implementation while preserving architecture
The result: a component that's more flexible and less complex than traditional approaches.
That's the paradox worth showcasing.
Tip
For Interviewers: The use of RegExp parsing instead of formatToParts() demonstrates an important skill—knowing when implementation details can be improved while the core architecture remains sound. The compositional pattern is the valuable insight here; the parsing mechanism is just an implementation detail that can evolve with the platform.