Exploring TypeScript Techniques for Designing Composite Components in React
Let’s build a composite component step by step with Typescript
Components allow you to encapsulate and reuse code. Once you create a component, it can be utilized multiple times throughout your application —DRY (Don’t Repeat Yourself) principle. Therefore, it is crucial to design components with the principle of Separation of Concerns in mind.
Larger components can be constructed by composing multiple smaller components.
Let’s consider building composite component
Let’s consider building a composite component. Assume that we need to build a component named CompositeOptionbar
. This component consists of multiple sub-components, each requiring its own props.
CompositeOptionbar provides the following functionalities.
- Search Input
- Expand View
- Keyword highlight
- Table Setting
A CompositeOptionbar
must be able to optionally render each sub-component. As it should be capable of rendering multiple components, it needs to receive all the props required by the renderable components. If a particular component is not intended to be rendered, the props for that component should not be passed.
Build each optional components
Search Input
// HightlightSearch.tsx
export interface HightlightSearchProps {
onSearch: (value: string) => void;
placeholder?: string;
}
export const HightlightSearch = ({ placeholder, onSearch }: HightlightSearchProps) => {
return <Search onSearch={onSearch} placeholder={placeholder ?? 'Enter a keyword'} />
}
Expand View
// ExpandViewButton.tsx
export interface ExpandViewButtonProps {
isExpandView: boolean;
isLoading: boolean;
onClick: (isExpandView: boolean) => void;
}
export const ExpandViewButton = ({ isExpandView, isLoading, onClick }: ExpandViewButtonProps) => {
return (
<Tooltip title='Expand'>
<Button
variant='outline'
isLoading={isLoading}
icon={isExpandView ? 'fullscreen-exit' : 'fullscreen'}
type={isExpandView ? 'primary' : 'normal'}
onClick={() => onClick(isExpandView)}
/>
</Tooltip>
)
}
Keyword Highlight
// HighlightButton.tsx
type HexColorType = `#${string}`;
type HighlightType = { [keyword: string]: HexColorType };
export interface HighlightButtonProps {
highlights: HighlightType;
onAdd: (keyword: string, color: HexColorType) => void;
onDelete: (keyword: string) => void;
onUpdate: (keyword: string, color: HexColorType) => void;
isLoading: boolean;
highlightColors: Array<HexColorType>;
}
export const HighlightButton = ({ highlights, onAdd, onUpdate, onDelete, isLoading, highlightColors }: HighlightButtonProps) => {
const [isDropdownVisible, setIsDropdownVisible] = useState<boolean>(false);
const onVisibleChange = (visible: boolean): void => {
setIsDropdownVisible(visible);
};
return (
<Dropdown
overlay={
<HighlightPalette
isLoading={isLoading}
highlights={highlights}
onAdd={onAdd}
onUpdate={onUpdate}
onDelete={onDelete}
colors={colors}
/>
}
trigger='click'
visible={isDropdownVisible}
onChange={onVisibleChange}
>
<Tooltip title='Highlight'>
<Button loading={isLoading} icon='highlight' type='normal' variant='outline' />
</Tooltip>
</Dropdown>
)
}
TableSetting
// SettingMenuButton.tsx
import type { ColumnSettingProps } from './ColumnSetting';
import type { TableSettingProps } from './TableSetting';
export type SettingMenuButtonProps = {
isLoading: boolean;
} & ColumnSettingProps & TableSettingProps;
type MenuType = 'ColumnSetting' |'TableSetting';
const MenuList: Array<MenuType> = ['ColumnSetting', 'TableSetting'];
export const SettingMenuButton = ({ isLoading, columns, onChangeColumnSetting, tableSetting, onChangeTableSetting }: SettingMenuButtonProps) => {
const [selectedMenu, setSelectedMenu] = useState<MenuType | null>(null);
const onSelectMenu = (menuKey: MenuType): void => {
setSelectedMenu(menuKey);
};
const onUnSelectMenu = (): void => {
setSelectedMenu(null);
};
const menu = useMemo(
() => (
<Menu onClick={(key) => onSelectMenu(key)}>
{MenuList.map((menuKey) => (
<MenuItem key={menuKey}>{menuKey}</MenuItem>
))}
</Menu>
),
[],
);
const modalContent = useMemo(() => {
switch (selectedMenu) {
case 'ColumnSetting':
return (
<ColumnSetting
columns={columns}
onChangeColumnSetting={onChangeColumnSetting}
/>
);
case 'TableSetting':
return (
<TableSetting
tableSetting={tableSetting}
onChangeTableSetting{onChangeTableSetting}
/>
);
default:
return <></>;
}
}, [selectedMenu, columns, onChangeColumnSetting, tableSetting, onChangeTableSetting]);
return (
<>
<Dropdown overlay={menu} trigger={'click'}>
<Tooltip title='Table Setting'>
<Button icon='setting' variant='outline' isLoading={isLoading} />
</Tooltip>
</Dropdown>
<Modal
title='Setting Menu Modal'
visible={selectedMenu !== null}
onCancel={onUnSelectMenu}
>
{modalContent}
</Modal>
</>
)
}
Implementing a type guard for props
Bad Example
The easiest way we can come up with is to make all props optional. Partial<Type> set all properties of Type optional.
import { type HightlightSearchProps, HightlightSearch } from './HightlightSearch';
import { type ExpandViewButtonProps, ExpandViewButton } from './ExpandViewButton';
import { type HighlightButtonProps, HighlightButton } from './HighlightButton';
import { type SettingMenuButtonProps, SettingMenuButton } from './SettingMenuButton';
type CompositeOptionbarProps = Partial<HightlightSearchProps> &
Partial<ExpandViewButtonProps> &
Partial<HighlightButtonProps> &
Partial<SettingMenuButtonProps>;
const CompositeOptionbar = (props: CompositeOptionbarProps) => {
const {
onSearch,
placeholder,
isExpandView,
isLoading,
onClick,
// more props...
} = props;
const isHightlightSearchVisible = onSearch === typeof 'function';
const ExpandViewButton = isExpandView !== undefined &&
isLoading === typeof 'boolean' &&
onClick === typeof 'function';
// more type guard...
return (
{isHightlightSearchVisible && <HightlightSearch onSearch={onSearch} placeholder={placeholder && placeholder} />}
{ExpandViewButton && <ExpandViewButton isExpandView={isExpandView} isLoading={isLoading} onClick={onClick} />}
// more components...
)
}
In this way, each component’s props are mixed together. onSearch
and placeholder
is for HightlightSearch. isExpandView
, isLoading
, and onClick
is for ExpandViewButton.
Having all of the entangled props without distinction makes it hard to figure out which props should be passed to which components. It also increases the number of type guards because you have to identify using passed props whether to render the component or not. If you need to render more components, it will become increasingly complex and difficult to maintain.
How about this way? (Alternative)
Consider this alternative approach.
// CompositeOptionbar.type.tsx
import type { HightlightSearchProps } from './HightlightSearch';
import type { ExpandViewButtonProps } from './ExpandViewButton';
import type { HighlightButtonProps } from './HighlightButton';
import type { SettingMenuButtonProps } from './SettingMenuButton';
// HightlightSearch
interface HightlightSearch extends HightlightSearchProps {
useHighlightSearch: true;
}
interface WithoutHightlightSearch {
useHighlightSearch: false;
}
type HightlightSearchType = HightlightSearch | WithoutHightlightSearch;
// ExpandViewButton
interface ExpandViewButton extends ExpandViewButtonProps {
useExpandViewButton: true;
}
interface WithoutExpandViewButton {
useExpandViewButton: false;
}
type ExpandViewButtonType = ExpandViewButton | WithoutExpandViewButton;
// ExpandViewButton
interface HighlightButton extends HighlightButtonProps {
useHighlightButton: true;
}
interface WithoutHighlightButton {
useHighlightButton: false;
}
type HighlightButtonType = HighlightButton | WithoutHighlightButton;
// SettingMenuButton
interface SettingMenuButton extends SettingMenuButtonProps {
useSettingMenuButton: true;
}
interface WithoutSettingMenuButton {
useSettingMenuButton: false;
}
type SettingMenuButtonType = SettingMenuButton | WithoutSettingMenuButton;
// CompositeOptionbar *
export type CompositeOptionbarProps = HightlightSearchType &
ExpandViewButtonType &
HighlightButtonType &
SettingMenuButtonType;
Let’s look closer. HighlightSearch component is optional. If it should be rendered, it requires useHighlightSearch
flag to be true and onSearchProps. Otherwise, If it should be not rendered, useHighlightSearch
is set to false.
interface HightlightSearch extends HightlightSearchProps {
useHighlightSearch: true;
}
interface WithoutHightlightSearch {
useHighlightSearch: false;
}
type HightlightSearchType = HightlightSearch | WithoutHightlightSearch;
// HightlightSearch.tsx
export interface HightlightSearchProps {
onSearch: (value: string) => void;
placeholder?: string;
}
Here is the CompositeOptionbar
component. It is simple but also has a well-functioning type guard!
// CompositeOptionbar.tsx
import type { CompositeOptionbarProps } from './CompositeOptionbar.type';
import { type HightlightSearchProps, HightlightSearch } from './HightlightSearch';
import { type ExpandViewButtonProps, ExpandViewButton } from './ExpandViewButton';
import { type HighlightButtonProps, HighlightButton } from './HighlightButton';
import { type SettingMenuButtonProps, SettingMenuButton } from './SettingMenuButton';
const CompositeOptionbar = (props: CompositeOptionbarProps) => {
const {
useHighlightSearch = true,
useExpandViewButton = true,
useHighlightButton = true,
useSettingMenuButton = true
} = props;
return (
<>
{useHighlightSearch && (
<HightlightSearch
onSearch={(props as HightlightSearchProps).onSearch}
/>
)}
{useExpandViewButton && (
<ExpandViewButton
isExpandView={(props as ExpandViewButtonProps).isExpandView}
isLoading={(props as ExpandViewButtonProps).isLoading}
onClick={(props as ExpandViewButtonProps).onClick}
/>
)}
{useHighlightButton && (
<HighlightButton
highlights={(props as HighlightButtonProps).highlights}
onAdd={(props as HighlightButtonProps).onAdd}
onDelete={(props as HighlightButtonProps).onDelete}
onUpdate={(props as HighlightButtonProps).onUpdate}
isLoading={(props as HighlightButtonProps).isLoading}
highlightColors={(props as HighlightButtonProps).highlig}
/>
)}
{useSettingMenuButton && (
<SettingMenuButton
isLoading={(props as SettingMenuButtonProps).isLoading}
columns={(props as SettingMenuButtonProps).columns}
onChangeColumnSetting={(props as SettingMenuButtonProps).onChangeColumnSetting}
tableSetting={(props as SettingMenuButtonProps).tableSetting}
onChangeTableSetting={(props as SettingMenuButtonProps).onChangeTableSetting}
/>
)}
</>
)
}
export default CompositeOptionbar;
You can compose CompositeOptionbar
in various ways, such as with HightlightSearch
and ExpandViewButton
or HightlightSearch
and TableSetting
.
import CompositeOptionbar from './CompositeOptionbar';
const TestComponent1 = (props) => {
const { onSearch, isExpandView, onChangeIsExpandView } = props;
return (
<CompositeOptionbar
useHighlightSearch={true}
onSearch={onSearch}
needExpandView={true}
isExpandView={isExpandView}
onChangeIsExpandView={onChangeIsExpandView}
needKeywordHighlight={false}
needLogTableSettingMenu={false}
/>
// ... more
)
}
const TestComponent2 = (props) => {
const {
onSearch,
isLoading,
columns,
onChangeColumnSetting,
tableSetting,
onChangeTableSetting
} = props;
return (
<CompositeOptionbar
useHighlightSearch={true}
onSearch={onSearch}
needExpandView={false}
needKeywordHighlight={false}
needLogTableSettingMenu={true}
isLoading={isLoading}
columns={columns}
onChangeColumnSetting={onChangeColumnSetting}
tableSetting={tableSetting}
onChangeTableSetting={onChangeTableSetting}
/>
// ... more
)
}
If you set useHighlightSearch
to true but do not pass the required onSearch
prop when you want to render the HightlightSearch
component, don't worry—The Typescript compiler is going to yell at you kindly.
const TestComponent2 = (props) => {
const { onSearch } = props;
return (
<CompositeOptionbar
useHighlightSearch={true}
// onSearch={onSearch} Type Error: Property 'onSearch' is missing in type
needExpandView={false}
needKeywordHighlight={false}
needLogTableSettingMenu={false}
/>
// ... more
)
}
Conclusion
The approach I proposed might not be an answer. But I think that It is an interesting pattern that can be used effectively in certain scenarios. If you have ideas related to different types of design patterns, feel free to let me know at any time.
Happy Coding!