Exploring TypeScript Techniques for Designing Composite Components in React

Let’s build a composite component step by step with Typescript

Suyeon Kang
suyeonme
6 min readJan 8, 2024

--

Photo by Mike Hindle on Unsplash

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 Component

CompositeOptionbar provides the following functionalities.

  • Search Input
  • Expand View
  • Keyword highlight
  • Table Setting
CompositeOptionbar Component

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.

TestComponent1(Left), TestComponent2(Right)
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!

--

--