Mastering Component Encapsulation: 17 Essential Principles for React Developers
This article shares years of component‑encapsulation experience and lessons drawn from popular UI libraries, presenting seventeen practical principles—ranging from basic property binding to error handling and multilingual support—to help developers build robust, reusable React components that avoid common pitfalls.
This article summarizes years of component‑encapsulation experience and insights from well‑known UI libraries such as antd, element‑plus, vant, and fusion, offering essential principles for creating perfect reusable components in React (the ideas also apply to Vue).
Below is a React example, but the concepts are equally applicable to Vue.
1. Basic Property Binding Principle
Every component should inherit className and style properties.
import classNames from 'classnames';
export interface CommonProps {
/** custom class name */
className?: string;
/** custom inline style */
style?: React.CSSProperties;
}
export interface MyInputProps extends CommonProps {
/** value */
value: any;
}
const MyInput = forwardRef((props: MyInputProps, ref: React.LegacyRef<HTMLDivElement>) => {
const { className, ...rest } = props;
const displayClassName = classNames('chc-input', className);
return (
<div ref={ref} {...rest} className={displayClassName}>
<span></span>
</div>
);
});
export default MyInput;2. Comment Usage Principle
All props and ref attributes should have JSDoc comments.
Disable // comment syntax because it is not recognized by TypeScript and will not appear on hover.
Common annotation tags: @description (description), @version (starting version), @deprecated (deprecated version), @default (default value).
For internationally used components, write comments in English.
bad ❌
interface MyInputsProps {
// custom class
className?: string;
}
const test: MyInputsProps = {};
test.className;after good ✅
interface MyInputsProps {
/** custom class */
className?: string;
/**
* @description Custom inline style
* @version 2.6.0
* @default ''
*/
style?: React.CSSProperties;
/**
* @description Custom title style
* @deprecated 2.5.0
* @default ''
*/
customTitleStyle?: React.CSSProperties;
}
const test: MyInputsProps = {};
test.className;3. Export Exposure Principle
Component props types must be exported.
If useImperativeHandle is used, the ref type must also be exported.
Component functions should have a name; default export is recommended.
Component without a name causes ambiguous errors.
bad ❌
interface MyInputProps {
// ...
}
export default (props: MyInputProps) => {
return <div></div>;
};after good ✅
// Export MyInputProps type
export interface MyInputProps {
// ...
}
function MyInput(props: MyInputProps) {
return <div></div>;
}
export default MyInput;index.ts
export * from './input';
export { default as MyInput } from './input';If the component does not expose its types, you can use ComponentProps and ComponentRef to retrieve props and ref types.
type DialogProps = ComponentProps<typeof Dialog>;
type DialogRef = ComponentRef<typeof Dialog>;4. Input Type Constraint Principle
When possible, avoid using generic primitive types; prefer explicit literal unions or descriptive comments.
Avoid using plain string for status; use 'success' | 'fail' instead.
Add range information to numeric fields, e.g., /** total count 0‑999 */.
bad ❌
interface InputProps {
status: string;
}after good ✅
interface InputProps {
status: 'success' | 'fail';
}bad ❌
interface InputProps {
/** total count */
count: number;
}after good ✅
interface InputProps {
/** total count 0‑999 */
count: number;
}5. Class and Style Definition Rules
Avoid CSS modules that prevent users from overriding internal styles; use scoped styles in Vue if needed.
All internal class names should have a unified prefix to avoid collisions.
Class names should be semantic.
All internal classes should be overridable by external users.
Avoid !important and inline styles unless absolutely necessary.
Expose CSS variables for color‑related properties to support theme switching.
bad ❌
import styles from './index.module.less';
export default function MyInput(props: MyInputProps) {
return (
<div className={styles.input_box}>
<span className={styles.detail}>21312312</span>
</div>
);
}after good ✅
import './index.less';
const prefixCls = 'my-input'; // unified prefix
export default function MyInput(props: MyInputProps) {
return (
<div className={`${prefixCls}-box`}>
<span className={`${prefixCls}-detail`}>21312312</span>
</div>
);
} .my-input-box {
height: 100px;
background: var(--my-input-box-background, #000);
}6. Inheritance and Prop‑Pass‑Through Principle
When wrapping a base component, extend its props and spread ...rest to forward all received attributes.
bad ❌
import { Input } from 'some-lib';
export interface MyInputProps {
/** value */
value: string;
/** limit */
limit: number;
/** state */
state: string;
}
const MyInput = (props: Partial<MyInputProps>) => {
const { value, limit, state } = props;
// ...
return <Input value={value} limit={limit} state={state} />;
};
export default MyInput;after good ✅
import { Input, InputProps } from 'some-lib';
export interface MyInputProps extends InputProps {
/** value */
value: string;
}
const MyInput = (props: Partial<MyInputProps>) => {
const { value, ...rest } = props;
// ...
return <Input value={value} {...rest} />;
};
export default MyInput;7. Event Pairing Principle
Any UI change inside a component must expose a corresponding event hook for users.
bad ❌
export default function MyInput(props: MyInputProps) {
const [open, setOpen] = useState(false);
const [showDetail, setShowDetail] = useState(false);
const currClassName = classNames(className, {
`${prefixCls}-box`: true,
`${prefixCls}-open`: open,
});
const onCheckOpen = () => setOpen(!open);
const onShowDetail = () => setShowDetail(!showDetail);
return (
<div className={currClassName} onClick={onCheckOpen}>
<span onClick={onShowDetail}>{showDetail ? '123' : '...'}</span>
</div>
);
};after good ✅
export default function MyInput(props: MyInputProps) {
const { onChange, onShowChange } = props;
const [open, setOpen] = useState(false);
const [showDetail, setShowDetail] = useState(false);
const currClassName = classNames(className, {
`${prefixCls}-box`: true,
`${prefixCls}-open`: open,
});
const onCheckOpen = () => {
setOpen(!open);
onChange?.(!open); // emit open change
};
const onShowDetail = () => {
setShowDetail(!showDetail);
onShowChange?.(!showDetail); // emit detail change
};
return (
<div className={currClassName} onClick={onCheckOpen}>
<span onClick={onShowDetail}>{showDetail ? '123' : '...'}</span>
</div>
);
};8. Ref Binding Principle
Components that may receive a ref must expose a corresponding ref property; otherwise users will see console warnings.
Original component: use useImperativeHandle or bind ref to the root element.
interface ChcInputRef {
/** set view visibility */
setValidView?: (isShow?: boolean) => void;
/** field */
field: Field;
}
const ChcInput = forwardRef<ChcInputRef, MyProps>((props, ref) => {
const { className, ...rest } = props;
useImperativeHandle(ref, () => ({
setValidView(isShow = false) {
setIsCheckBalloonVisible(isShow);
},
field,
}), []);
return <div className={displayClassName}>...</div>;
});
export default ChcInput; const ChcInput = forwardRef((props: MyProps, ref: React.LegacyRef<HTMLDivElement>) => {
const { className, ...rest } = props;
const displayClassName = classNames('chc-input', className);
return (
<div ref={ref} className={displayClassName}>...</div>
);
});
export default ChcInput;9. Custom Extensibility Principle
Provide a user‑defined entry point (e.g., a render prop) so that complex internal logic can be overridden.
bad ❌
export default function MyInput(props: MyInputProps) {
const { value } = props;
const detailText = useMemo(() =>
value.split(',').map(item => `Component internal logic: ${item}`).join('
')
, [value]);
return (
<div>
<span>{detailText}</span>
</div>
);
};after good ✅
export default function MyInput(props: MyInputProps) {
const { value, render } = props;
const detailText = useMemo(() => {
return render ? render(value) : value.split(',').map(item => `Component internal logic: ${item}`).join('
');
}, [value, render]);
return (
<div>
<span>{detailText}</span>
</div>
);
};10. Controlled vs. Uncontrolled Mode Principle
Components should support both controlled and uncontrolled usage.
bad ❌ (only controlled)
export default function MyInput(props: MyInputProps) {
const { value, className, style, onChange } = props;
const currClassName = classNames(className, {
`${prefixCls}-box`: true,
`${prefixCls}-open`: value,
});
const onCheckOpen = () => onChange?.(!value);
return (
<div className={currClassName} style={style} onClick={onCheckOpen}>12312</div>
);
};after good ✅
export default function MyInput(props: MyInputProps) {
const { value, defaultValue = true, className, style, onChange } = props;
const [open, setOpen] = useState(value ?? defaultValue);
useEffect(() => {
if (typeof value === 'boolean') setOpen(value);
}, [value]);
const currClassName = classNames(className, {
`${prefixCls}-box`: true,
`${prefixCls}-open`: open,
});
const onCheckOpen = () => {
onChange?.(!open);
if (typeof value !== 'boolean') setOpen(!open);
};
return (
<div className={currClassName} style={style} onClick={onCheckOpen}>12312</div>
);
};11. Minimal Dependency Principle
Avoid adding new dependencies unless absolutely necessary; prefer hand‑written solutions when possible.
bad ❌ (adds ahooks)
import { useLatest } from 'ahooks';
import classNames from 'classnames';
const ChcInput = forwardRef((props: InputProps, ref) => {
const { className, ...rest } = props;
const displayClassName = classNames('chc-input', className);
const funcRef = useLatest(func); // new dependency
return <div className={displayClassName} {...rest} />;
});
export default ChcInput;after good ✅ (internal useLatest implementation)
// hooks/index.tsx
export function useLatest(value) {
const ref = useRef(value);
ref.current = value;
return ref;
}
// component
import { useLatest } from '@/hooks';
import classNames from 'classnames';
const ChcInput = forwardRef((props: InputProps, ref) => {
const { className, ...rest } = props;
const displayClassName = classNames('chc-input', className);
const funcRef = useLatest(func);
return <div className={displayClassName} {...rest} />;
});
export default ChcInput;12. Single Responsibility Principle
Split complex components into smaller, single‑purpose components to improve reusability and flexibility.
bad ❌ (combined table and image logic)
const MyShowPage = forwardRef((props: MyTableProps, ref) => {
const { data, imgList, ...rest } = props;
return (
<div>
<Table ref={ref} data={data} {...rest} />
<div>{/* image logic */}</div>
</div>
);
});after good ✅
const MyShowPage = forwardRef((props: MyTableProps, ref) => {
const { data, imgList, ...rest } = props;
return (
<div>
<MyTable ref={ref} data={data} {...rest} />
<MyImg data={imgList} />
</div>
);
});13. Distinguish Generic and Business Components
Generic components should contain only universal UI logic, while business components embed specific domain logic.
bad ❌ (generic table contains business formatting)
const MyTable = forwardRef((props: MyTableProps, ref) => {
const { data, columns, ...rest } = props;
const dataRender = item => Math.abs(item.value);
const styleRender = item => (item.value < 0 ? { color: 'red' } : undefined);
const tableColumns = useMemo(() =>
columns.map(item => {
if (item.name === 'value') {
return { ...item, render: dataRender, styleRender };
}
return { ...item };
})
, [columns]);
return <Table ref={ref} data={data} {...rest}>...</Table>;
});after good ✅ (business logic moved to usage side)
// Generic component
const MyTable = forwardRef((props: MyTableProps, ref) => {
const { data, columns, ...rest } = props;
return <Table ref={ref} data={data} {...rest}>...</Table>;
});
// Usage
const columns = [
{ title: 'Name', name: 'name' },
{
title: 'Value',
name: 'value',
render: item => Math.abs(item.value),
style: item => (item.value < 0 ? { color: 'red' } : undefined),
},
];
<MyTable data={list} columns={columns} />;14. Maximum Depth Extensibility
When a component receives tree‑structured data, implement recursive rendering to support unlimited depth.
bad ❌ (only one level)
interface Columns extends TableColumnProps {
columns: TableColumnProps[];
}
const MyTable = forwardRef((props: MyTableProps, ref) => {
const { data, columns = [], ...rest } = props;
const renderColumn = useMemo(() =>
columns.map(item =>
item.columns ? (
<Table.Column {...item}>
{item.columns.map(col => <Table.Column {...col} />)}
</Table.Column>
) : (
<Table.Column {...item} />
)
)
, [columns]);
return <Table ref={ref} data={data} {...rest}>{renderColumn}</Table>;
});after good ✅ (recursive component)
interface Columns extends TableColumnProps {
columns: Columns[]; // recursive type
}
const MyTable = forwardRef((props: MyTableProps, ref) => {
const { data, columns = [], ...rest } = props;
return (
<Table ref={ref} data={data} {...rest}>
<MyColumn columns={columns} />
</Table>
);
});
const MyColumn = (props: { columns: Columns[] }) => {
const { columns } = props;
return columns.map(item =>
item.columns ? (
<Table.Column {...item}>
<MyColumn columns={item.columns} />
</Table.Column>
) : (
<Table.Column {...item} />
)
);
};15. Multilingual Configurability
All textual content inside a component should be configurable to support multiple languages, with English as the default.
When many strings are needed, expose a strings prop object containing all replaceable keys.
bad ❌ (hard‑coded Chinese strings)
const prefixCls = 'my-input';
export default function MyInput(props: MyInputProps) {
const { title = '标题' } = props;
return (
<div className={`${prefixCls}-box`}>
<span className={`${prefixCls}-title`}>{title}</span>
<span className={`${prefixCls}-detail`}>详情</span>
</div>
);
}after good ✅
const prefixCls = 'my-input';
export default function MyInput(props: MyInputProps) {
const { title = 'title', detail = 'detail' } = props;
return (
<div className={`${prefixCls}-box`}>
<span className={`${prefixCls}-title`}>{title}</span>
<span className={`${prefixCls}-detail`}>{detail}</span>
</div>
);
}16. Error Handling and Messaging
When user‑provided parameters may cause errors, log a console.error message instead of throwing.
Use console.warn for non‑critical issues.
bad ❌ (no validation)
export default function MyCanvas(props: MyCanvasProps) {
const { instanceId } = props;
useEffect(() => {
initDom(instanceId);
}, []);
return (
<div>
<canvas id={instanceId} />
</div>
);
}after good ✅
export default function MyCanvas(props: MyCanvasProps) {
const { instanceId } = props;
useEffect(() => {
if (!instanceId) {
console.error('missing instanceId!');
return;
}
initDom(instanceId);
}, []);
return (
<div>
<canvas id={instanceId} />
</div>
);
}17. Semantic Naming Principle
Component names, APIs, methods, and internal variables must be semantically meaningful and accurately reflect their purpose.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
