# Extend Components
Learn how to create custom components that wrap and extend Dynamic Framework components.
# Composition Over Inheritance
Dynamic Framework favors composition over inheritance. Instead of extending classes, wrap components to add functionality:
// ✗ Avoid: inheritance
class MyButton extends DButton { }
// ✓ Prefer: composition
import type { ComponentProps } from 'react';
import { DButton } from '@dynamic-framework/ui-react';
type DButtonProps = ComponentProps<typeof DButton>;
function MyButton(props: DButtonProps) {
return <DButton {...props} />;
}
# Wrapper Components
Create wrapper components to add functionality like analytics, default props, or custom styling.
# Basic Wrapper
// src/components/PrimaryButton.tsx
import { DButton } from '@dynamic-framework/ui-react';
import type { ComponentPropsWithoutRef, MouseEvent } from 'react';
type DButtonProps = ComponentPropsWithoutRef<typeof DButton>;
interface PrimaryButtonProps extends Omit<DButtonProps, 'color'> {
trackingLabel?: string;
}
export function PrimaryButton({
onClick,
trackingLabel,
...props
}: PrimaryButtonProps) {
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
if (trackingLabel) {
// Your analytics logic
console.log('Track:', trackingLabel);
}
onClick?.(e);
};
return (
<DButton
{...props}
color="primary"
onClick={handleClick}
/>
);
}
# Usage
<PrimaryButton
text="Submit"
trackingLabel="submit-form"
onClick={() => handleSubmit()}
/>
# Compound Components
Create modular components using Dynamic's compound component pattern.
# Product Card Example
// src/components/ProductCard.tsx
import { DCard, DCurrencyText, DButton } from '@dynamic-framework/ui-react';
interface Product {
id: string;
name: string;
description: string;
price: number;
}
interface ProductCardProps {
product: Product;
onSelect?: (id: string) => void;
}
export function ProductCard({ product, onSelect }: ProductCardProps) {
return (
<DCard>
<DCard.Header>
<h5 className="mb-0">{product.name}</h5>
</DCard.Header>
<DCard.Body>
<p className="text-muted">{product.description}</p>
<DCurrencyText value={product.price} />
</DCard.Body>
<DCard.Footer>
<DButton
text="Select"
color="primary"
onClick={() => onSelect?.(product.id)}
/>
</DCard.Footer>
</DCard>
);
}
# Creating Your Own Compound Component
// src/components/InfoCard/index.tsx
import { DCard } from '@dynamic-framework/ui-react';
import type { ReactNode } from 'react';
interface InfoCardProps {
children: ReactNode;
className?: string;
}
interface InfoCardTitleProps {
children: ReactNode;
}
interface InfoCardContentProps {
children: ReactNode;
}
function InfoCard({ children, className }: InfoCardProps) {
return (
<DCard className={className}>
{children}
</DCard>
);
}
function InfoCardTitle({ children }: InfoCardTitleProps) {
return (
<DCard.Header>
<h5 className="mb-0">{children}</h5>
</DCard.Header>
);
}
function InfoCardContent({ children }: InfoCardContentProps) {
return <DCard.Body>{children}</DCard.Body>;
}
// Attach sub-components
InfoCard.Title = InfoCardTitle;
InfoCard.Content = InfoCardContent;
export { InfoCard };
# Usage
<InfoCard>
<InfoCard.Title>Account Summary</InfoCard.Title>
<InfoCard.Content>
<p>Your account details here</p>
</InfoCard.Content>
</InfoCard>
# Custom Hooks
Create hooks to encapsulate reusable logic.
# useToggle
// src/hooks/useToggle.ts
import { useState, useCallback } from 'react';
export function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue((v) => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
# Usage to Show or Hide a Value
import { DButton, DCurrencyText } from '@dynamic-framework/ui-react';
import { useToggle } from '@/hooks/useToggle';
function BalanceCard({ balance }: { balance: number }) {
const visible = useToggle();
return (
<div className="d-flex align-items-center gap-2">
{visible.value ? <DCurrencyText value={balance} /> : <span>••••••</span>}
<DButton
text={visible.value ? 'Hide balance' : 'Show balance'}
variant="link"
onClick={visible.toggle}
/>
</div>
);
}
# Opening a Modal from a Hook
DModal doesn't open through a state prop: it is registered as a portal in DContextProvider and opened with openPortal. See the full pattern in Portals. A custom hook can wrap the call so components don't repeat the portal name or the payload shape:
// src/hooks/useTransferConfirm.ts
import { useCallback } from 'react';
import { useDPortalContext } from '@dynamic-framework/ui-react';
// The same type DContextProvider<Portals> was configured with
type Portals = {
transferConfirm: { amount: string };
};
export function useTransferConfirm() {
const { openPortal } = useDPortalContext<Portals>();
return useCallback(
(amount: string) => openPortal('transferConfirm', { amount }),
[openPortal],
);
}
import { DButton } from '@dynamic-framework/ui-react';
import { useTransferConfirm } from '@/hooks/useTransferConfirm';
function TransferButton() {
const confirmTransfer = useTransferConfirm();
return <DButton text="Transfer" onClick={() => confirmTransfer('$100,000')} />;
}
# Testing Components
Use Vitest and React Testing Library to test your extended components.
# Setup
If your project doesn't start from the Modyo widget template, install the testing dependencies:
npm install -D vitest @testing-library/react @testing-library/jest-dom
# Test Example
// src/components/__tests__/PrimaryButton.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { PrimaryButton } from '../PrimaryButton';
describe('PrimaryButton', () => {
it('renders with text', () => {
render(<PrimaryButton text="Click me" />);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = vi.fn();
render(<PrimaryButton text="Click" onClick={handleClick} />);
fireEvent.click(screen.getByText('Click'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('logs tracking label when provided', () => {
const consoleSpy = vi.spyOn(console, 'log');
render(
<PrimaryButton text="Track me" trackingLabel="test-button" />
);
fireEvent.click(screen.getByText('Track me'));
expect(consoleSpy).toHaveBeenCalledWith('Track:', 'test-button');
});
});
# Best Practices
- Preserve the original API - Your wrapper should accept all props the original component accepts
- Use TypeScript - Extend component prop types for type safety
- Keep it simple - Only wrap when you need to add real value
- Test your components - Ensure wrappers behave correctly
- Document custom props - Make it clear what your wrapper adds