React Conventions
These rules target React-specific problems that ESLint, eslint-plugin-react, and eslint-plugin-react-hooks do NOT catch. General code quality issues (duplication, dead code, complexity) are handled by the code-quality skill — do not repeat them here.
Every rule below includes: severity, what to detect, how to detect it, and examples.
Rule: component-size
Severity: MEDIUM
Detect components that are too large. Threshold: >200 lines (non-blank, non-comment) or JSX return block >50 lines.
Detection: Count lines in each component file. If a single component exceeds the threshold, it likely handles too many responsibilities.
Bad:
// src/components/OrderPage/OrderPage.jsx — 350 lines
const OrderPage = () => {
// 40 lines of state declarations
// 60 lines of useEffect calls
// 30 lines of handler functions
// 20 lines of data transformations
// 200 lines of JSX
return (
<div>
{/* header, filters, table, pagination, modals, toasts... */}
</div>
);
};
Good:
const OrderPage = () => {
const { orders, filters, pagination } = useOrders();
return (
<div>
<OrderFilters {...filters} />
<OrderTable orders={orders} />
<Pagination {...pagination} />
</div>
);
};
Report format: Show file path, line count, and suggest splitting into smaller components or extracting logic into custom hooks.
Rule: business-logic-in-view
Severity: HIGH
Detect API calls, data transformations, or complex business logic placed directly inside the component body instead of being extracted to custom hooks or service modules.
Detection: Look for components that contain:
- Direct
fetchor API client calls (e.g.,axios.get,api.post) inside the component - Data transformation logic (
.map,.filter,.reducechains >5 lines) in the component body - Complex conditionals (>3 branches) that determine business outcomes (not rendering)
Bad:
const UserList = () => {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => {
const active = data.filter(u => u.status === 'active');
const sorted = active.sort((a, b) => b.lastLogin - a.lastLogin);
const withFullName = sorted.map(u => ({
...u,
fullName: `${u.firstName} ${u.lastName}`,
}));
setUsers(withFullName);
});
}, []);
return <ul>{users.map(u => <li key={u.id}>{u.fullName}</li>)}</ul>;
};
Good:
const UserList = () => {
const { users } = useActiveUsers();
return <ul>{users.map(u => <li key={u.id}>{u.fullName}</li>)}</ul>;
};
// Logic extracted to src/hooks/useActiveUsers.js
Report format: Show the component name, file:line, what logic should be extracted, and suggest a custom hook or service.
Rule: prop-drilling
Severity: MEDIUM
Detect props passed through 3+ levels of components where intermediate components do not use them.
Detection: Trace a prop from a parent component through its children. If a prop appears in a component's parameter list and is only forwarded to a child (not read or used in any logic or JSX), count it as a passthrough. Flag when a prop passes through 3 or more components that don't use it.
Bad:
// App → Layout → Sidebar → UserMenu → Avatar
// "user" prop passed through Layout and Sidebar without being used
const Layout = ({ user, children }) => (
<div>
<Sidebar user={user} />
{children}
</div>
);
const Sidebar = ({ user }) => (
<nav>
<UserMenu user={user} />
</nav>
);
Good:
// Use Context — Layout and Sidebar don't need the user prop
const UserMenu = () => {
const user = useContext(UserContext);
return <Avatar src={user.avatar} />;
};
Report format: Show the prop name, the chain of components it passes through, and suggest Context or composition.
Rule: index-as-key
Severity: HIGH
Detect usage of array index as key prop in lists where items can be added, removed, reordered, or filtered.
Detection: Find .map((item, index) => <X key={index} or .map((_, i) => <X key={i} patterns. Only flag if the list is dynamic (items come from state or props, not a static array defined outside the component).
Bad:
{orders.map((order, index) => (
<OrderRow key={index} order={order} />
))}
Good:
{orders.map((order) => (
<OrderRow key={order.id} order={order} />
))}
Why it matters: Using index as key causes React to reuse DOM nodes incorrectly when items are reordered or removed, leading to stale state in child components, broken animations, and input fields losing their values.
Report format: Show file:line, the .map expression, and suggest using a stable unique identifier from the data.
Rule: missing-cleanup
Severity: HIGH
Detect useEffect hooks that create subscriptions, timers, or event listeners without returning a cleanup function.
Detection: Look for useEffect bodies that contain:
setIntervalorsetTimeoutwithout a correspondingclearInterval/clearTimeoutin the returnaddEventListenerwithoutremoveEventListenerin the return- WebSocket/EventSource
.onor.subscribewithout.offor.unsubscribein the return - Any
.subscribe()call without.unsubscribe()in the return
Bad:
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
}, []);
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
Good:
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
Report format: Show file:line, what resource is created, and that no cleanup is returned.
Rule: derived-state
Severity: MEDIUM
Detect useState for values that can be computed from existing state or props, causing unnecessary state synchronization.
Detection: Look for:
- A
useStatewhose value is always derived from another state or prop (e.g.,const [fullName, setFullName] = useState(...)that is alwaysfirstName + lastName) - A
useEffectthat only exists to sync one piece of state from another (e.g.,useEffect(() => setFilteredItems(items.filter(...)), [items]))
Bad:
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
setFilteredItems(items.filter(i => i.name.includes(searchTerm)));
}, [items, searchTerm]);
Good:
const [items, setItems] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const filteredItems = items.filter(i => i.name.includes(searchTerm));
Why it matters: Derived state causes an extra render cycle (first render with stale value, then re-render with derived value), adds unnecessary complexity, and is a common source of sync bugs.
Report format: Show the redundant useState and the useEffect that syncs it, and suggest computing it inline or with useMemo.
Rule: component-in-component
Severity: HIGH
Detect React components defined inside other components. This causes the inner component to be unmounted and remounted on every render of the parent, destroying all its state.
Detection: Look for function declarations or arrow functions that return JSX and are defined inside another component's body (not at module level).
Bad:
const ParentComponent = () => {
const ChildComponent = ({ name }) => {
return <span>{name}</span>;
};
return <ChildComponent name="test" />;
};
Good:
const ChildComponent = ({ name }) => {
return <span>{name}</span>;
};
const ParentComponent = () => {
return <ChildComponent name="test" />;
};
Note: Inline render functions (not used as <Component /> but called directly like {renderItem(item)}) are acceptable.
Report format: Show the inner component name, file:line, and explain the remount problem.
Rule: direct-state-mutation
Severity: HIGH
Detect direct mutation of state objects or arrays instead of creating new references.
Detection: Look for patterns where state is modified in place:
.push(),.pop(),.splice(),.sort(),.reverse()on state arrays- Direct property assignment on state objects (e.g.,
user.name = 'new') - Mutations followed by
setState(sameReference)
Bad:
const addItem = (item) => {
items.push(item); // mutates the array
setItems(items); // same reference — React won't re-render
};
Good:
const addItem = (item) => {
setItems(prev => [...prev, item]);
};
Why it matters: React uses reference equality to detect changes. Mutating the existing object and passing the same reference means React won't re-render, causing stale UI.
Report format: Show file:line, the mutation method, and suggest spread operator or functional update.
Rule: useeffect-as-handler
Severity: MEDIUM
Detect useEffect used to respond to user events instead of handling logic directly in the event handler.
Detection: Look for a pattern where:
- An event handler sets a piece of state (e.g.,
setSubmitted(true)) - A
useEffectwatches that state and performs the actual logic
Bad:
const [submitted, setSubmitted] = useState(false);
const handleSubmit = () => {
setSubmitted(true);
};
useEffect(() => {
if (submitted) {
sendFormData(formData);
setSubmitted(false);
}
}, [submitted]);
Good:
const handleSubmit = () => {
sendFormData(formData);
};
Why it matters: This pattern creates an unnecessary render cycle, makes the flow harder to follow, and introduces potential bugs with stale closures and race conditions.
Report format: Show the state variable acting as a trigger, the handler that sets it, and the useEffect that reacts to it. Suggest moving the logic into the handler.
Rule: hook-sharing
Severity: LOW
Detect hook patterns that are duplicated across components and could be extracted into a shared custom hook.
Detection: Look for two or more components in the same module (or in scope) that contain similar useState + useEffect combinations:
- Same fetch-then-setState pattern with different endpoints
- Same loading/error/data state triple
- Same pagination logic (page, pageSize, total)
- Same form handling pattern (values, errors, touched, handleChange, handleSubmit)
Also check if the project already has a custom hook in src/common/hooks/ or src/hooks/ that provides the same functionality being reimplemented in a component.
Bad:
// src/pages/Orders.jsx
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
useEffect(() => { /* fetch /orders */ }, []);
// src/pages/Returns.jsx
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
useEffect(() => { /* fetch /returns */ }, []);
Good:
// src/hooks/useFetch.js — single implementation
const useFetch = (url) => { /* loading, error, data logic */ };
// src/pages/Orders.jsx
const { loading, error, data } = useFetch('/orders');
Report format: Show the duplicated pattern, the files involved, and suggest extracting to a custom hook.
Rule: spread-props-on-dom
Severity: MEDIUM
Detect spreading unknown props directly onto native DOM elements, which can pass invalid HTML attributes to the DOM.
Detection: Look for components that receive ...props or ...rest and spread them onto a native element (<div {...props}>, <input {...rest}>). Only flag when the component does not filter the props before spreading.
Bad:
const Card = (props) => <div {...props}>{props.children}</div>;
// Caller: <Card isActive onRetry={fn} /> → "isActive" and "onRetry" end up as DOM attributes
Good:
const Card = ({ isActive, onRetry, children, ...domProps }) => {
return <div {...domProps}>{children}</div>;
};
Report format: Show file:line, the spread expression, and suggest destructuring known props first.
Rule: no-ternary-rendering
Severity: MEDIUM
Detect conditional rendering implemented as nested ternaries inside JSX instead of early returns.
Detection: Look for components where loading states, error states, or empty states are handled as ternary expressions (? :) inside the main return block instead of as separate if blocks with their own return before the main JSX.
Bad:
const OrderList = ({ loading, error, orders }) => {
return (
<Container>
{loading ? (
<Skeleton />
) : error ? (
<ErrorMessage error={error} />
) : orders.length === 0 ? (
<EmptyState />
) : (
orders.map(o => <OrderRow key={o.id} order={o} />)
)}
</Container>
);
};
Good:
const OrderList = ({ loading, error, orders }) => {
if (loading) {
return <Skeleton />;
}
if (error) {
return <ErrorMessage error={error} />;
}
if (!orders.length) {
return <EmptyState />;
}
return (
<Container>
{orders.map(o => <OrderRow key={o.id} order={o} />)}
</Container>
);
};
Why it matters: Nested ternaries are hard to read, hard to extend, and hide the component's control flow. Early returns make each state explicit and keep the happy-path JSX flat.
Report format: Show file:line, the nested ternary depth, and suggest refactoring to early returns.
Severity Reference
| Severity | Meaning | Action |
|---|---|---|
| HIGH | Causes bugs, memory leaks, or broken rendering. Should fix before merge. | Block PR |
| MEDIUM | Maintainability or performance concern. Should fix or justify. | Discuss in review |
| LOW | Improvement opportunity. Nice to fix but not blocking. | Optional |
What This Skill Does NOT Cover
These are intentionally excluded — use a linter, a dedicated tool, or a separate skill:
- Hook call order and rules of hooks (
eslint-plugin-react-hooks) - Exhaustive deps in useEffect (
eslint-plugin-react-hooks) - Prop types validation (ESLint or PropTypes)
- Accessibility (
eslint-plugin-jsx-a11y) - Formatting and naming (Prettier, ESLint)
- General code quality issues (use
code-qualityskill)