TypeScript React Best Practices
This document outlines the agreed-upon best practices for TypeScript React components in the TWIN UI Components React package. Following these guidelines ensures consistency across the codebase and promotes maintainable, type-safe code.
Table of Contents
- Component Definition
- Props Typing
- State Management
- Lifecycle Methods Conversion
- Event Handlers
- Performance Optimization
- Refs
- Context
- Custom Hooks
- React's Rules of Hooks
- Security Best Practices
- Type Assertions
- Default Props
- Type vs. Interface
- Type Imports and Exports
- Component Structure Template
Component Definition
- Define components as regular functions, not using FC/FunctionComponent type:
export const MyComponent = ({ prop1, prop2 }: MyComponentProps): JSX.Element => {
// Component logic
return (
// JSX
);
};
- Always include explicit return type (JSX.Element) for component functions
- For forwardRef components, include explicit types for both props and ref:
export const MyComponent = memo(
forwardRef<HTMLDivElement, MyComponentProps>(
({ prop1, prop2 }: MyComponentProps, ref): JSX.Element => {
// Component logic
return (
// JSX
);
}
)
);
Props Typing
- Define props in a separate interface file (e.g.,
componentNameProps.ts
) - Use explicit ReactNode for children instead of PropsWithChildren
- Use descriptive, specific types (avoid any/unknown)
- Make optional props explicit with ? notation
// In tooltipProps.ts
interface TooltipProps {
content: string;
position?: 'top' | 'right' | 'bottom' | 'left';
children?: ReactNode;
onOpen?: () => void;
}
State Management
- Replace class component state with useState hooks
- Group related state in objects when appropriate
const [isLoading, setIsLoading] = useState<boolean>(false);
const [data, setData] = useState<DataType | null>(null);
Lifecycle Methods Conversion
- componentDidMount → useEffect with empty dependency array
- componentDidUpdate → useEffect with dependencies
- componentWillUnmount → useEffect return function
useEffect(() => {
// componentDidMount logic
return () => {
// componentWillUnmount logic
};
}, []);
Event Handlers
- Use useCallback for event handlers to prevent unnecessary re-renders
- Type event parameters explicitly
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
// Handler logic
},
[dependencies]
);
Performance Optimization
- Wrap component with memo for shallow prop comparison
- Use useMemo for expensive computations
- Properly manage dependencies in useCallback and useMemo
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
export const MyComponent = memo(({ props }: Props): JSX.Element => {
// Component logic
});
Refs
- Use useRef with proper typing
const inputRef = useRef<HTMLInputElement>(null);
Context
- Use useContext for consuming context
const themeContext = useContext<Theme>(ThemeContext);
Custom Hooks
- Extract reusable logic into custom hooks
- Name custom hooks with 'use' prefix
- Return properly typed values
function useCustomHook<T>(param: T): [T, (newValue: T) => void] {
// Hook logic
}
React's Rules of Hooks
Always follow React's Rules of Hooks to ensure components work correctly:
- Only call hooks at the top level of your component or custom hooks
- Never call hooks inside loops, conditions, or nested functions
- Never call hooks inside callback functions (like useCallback or event handlers)
- Always use the same order of hook calls between renders
// ❌ INCORRECT: Calling a hook inside another hook
const handleEvent = useCallback(() => {
const value = useMemo(() => computeValue(), []); // This will cause errors!
// ...
}, []);
// ✅ CORRECT: Move the hook to the component level
const value = useMemo(() => computeValue(), []);
const handleEvent = useCallback(() => {
// Use the pre-computed value here
// ...
}, [value]);
ESLint Enforcement
The project uses ESLint with the react-hooks/rules-of-hooks
rule (set to "error") to automatically catch violations of React's Rules of Hooks. This helps prevent runtime errors by identifying hook usage issues during development.
The react-hooks/exhaustive-deps
rule (set to "warn") is also enabled to help identify missing dependencies in hooks like useEffect
and useCallback
.
To run the linter and check for hook-related issues:
npm run lint
To automatically fix issues where possible:
npm run lint:fix
Security Best Practices
- Avoid dangerouslySetInnerHTML unless content is sanitized
- Use TypeScript's strict mode
- Validate all external data with proper type guards
// Only if necessary and with sanitization
const sanitizedHtml = sanitizeHtml(content);
<div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />;
Type Assertions
- Minimize use of type assertions (as)
- Use type guards instead when possible
// Prefer this
function isUser(obj: unknown): obj is User {
return obj !== null && typeof obj === 'object' && 'id' in obj;
}
// Over this
const user = data as User;
Default Props
- Use default parameter values instead of defaultProps
export const MyComponent = ({ prop1 = 'default', prop2 = 0 }: MyComponentProps): JSX.Element => {
// Component logic
};
Type vs. Interface
- Use
interface
for object shapes that may be extended, public API contracts, React component props - Use
type
for unions, intersections, complex types, tuples, and types that won't be extended
Type Imports and Exports
- Use explicit
type
imports:import type { ReactNode } from 'react';
- Export types that might be reused elsewhere
Component Structure Template
For consistency, follow this component structure template:
import { memo, useCallback, useEffect, useMemo, useRef, useState, type JSX } from 'react';
// Import props interface from separate file
import type { ComponentProps } from './componentProps';
/**
* Component description
*/
export const ComponentName = memo(({ prop1, prop2, onEvent }: ComponentProps): JSX.Element => {
// State hooks
const [stateVar, setStateVar] = useState<StateType | null>(null);
// Refs
const refVar = useRef<RefType | null>(null);
// Effects
useEffect(() => {
// Setup code
return () => {
// Cleanup code
};
}, []);
// Event handlers
const handleEvent = useCallback(
(event: EventType) => {
// Handler logic
if (onEvent) {
onEvent(someValue);
}
},
[onEvent]
);
// Memoized calculations
const computedValue = useMemo(() => {
// Computation
return result;
}, [dependencies]);
// Render JSX
return <div>{/* Component JSX */}</div>;
});
// Set display name for debugging
ComponentName.displayName = 'ComponentName';
Class to Functional Component Conversion
When converting class components to functional components, follow this checklist:
-
Props:
- Define a clear interface for props
- Use specific types, not any/unknown
- Make optional props explicit with ?
-
State:
- Convert this.state to useState hooks
- Split complex state into multiple useState calls or use useReducer
- Initialize state with proper types
-
Lifecycle Methods:
- componentDidMount → useEffect with empty dependency array
- componentDidUpdate → useEffect with dependencies
- componentWillUnmount → useEffect return function
- getDerivedStateFromProps → useEffect with prop dependencies
- getSnapshotBeforeUpdate → useLayoutEffect (rare cases)
-
Instance Variables:
- Convert to useRef hooks with proper typing
- Access via .current property
-
Event Handlers:
- Wrap with useCallback to prevent unnecessary re-renders
- Include dependencies in dependency array
- Type event parameters explicitly
-
Performance Optimization:
- Wrap component with memo for shallow prop comparison
- Use useMemo for expensive computations
- Properly manage dependencies in useCallback and useMemo
-
Context:
- Replace this.context with useContext hook
- Type context properly
-
Security:
- Sanitize any HTML content before using dangerouslySetInnerHTML
- Validate external data with type guards
- Use TypeScript's strict mode
-
Cleanup:
- Remove all class-specific code (this, bind, constructor)
- Set displayName for debugging
- Ensure no PropTypes are used
-
Return Types:
- Always include explicit return type (JSX.Element) for component functions
- Import JSX type with
import { type JSX } from 'react'
orimport { memo, useMemo, type JSX } from 'react'
Real-World Example
Here's a real-world example from our codebase showing these best practices in action:
// tooltipProps.ts
export interface TooltipProps {
content: string | ReactNode;
children: ReactNode;
placement?: TooltipPlacement;
animation?: TooltipAnimation;
style?: TooltipStyle;
trigger?: TooltipTrigger;
arrow?: boolean;
className?: string;
onOpen?: () => void;
onClose?: () => void;
}
// tooltip.tsx
import { memo, useCallback, useEffect, useRef, useState, type JSX } from 'react';
import type { TooltipProps } from './tooltipProps';
export const Tooltip = memo(
({
content,
children,
placement = 'top',
animation = 'fade',
style = 'dark',
trigger = 'hover',
arrow = true,
className,
onOpen,
onClose
}: TooltipProps): JSX.Element => {
const [isVisible, setIsVisible] = useState<boolean>(false);
const tooltipRef = useRef<HTMLDivElement>(null);
const handleOpen = useCallback(() => {
setIsVisible(true);
if (onOpen) {
onOpen();
}
}, [onOpen]);
const handleClose = useCallback(() => {
setIsVisible(false);
if (onClose) {
onClose();
}
}, [onClose]);
// Event handlers based on trigger type
useEffect(() => {
const tooltipElement = tooltipRef.current;
if (!tooltipElement) return;
// Add event listeners based on trigger type
return () => {
// Clean up event listeners
};
}, [trigger, handleOpen, handleClose]);
return (
<div className={`tooltip-wrapper ${className || ''}`} ref={tooltipRef}>
{children}
{isVisible && (
<div
className={`tooltip tooltip-${style} tooltip-${placement} animation-${animation} ${arrow ? 'tooltip-arrow' : ''}`}
>
{content}
</div>
)}
</div>
);
}
);
Tooltip.displayName = 'Tooltip';