Agent Rules / Frameworks
Do higher-order components hide props from coding agents?
Rule AR-36
In React, prefer hooks and explicit props over higher-order components that inject props.
A higher-order component (HOC) wraps a component and hands it props that no parent writes. Open the component's file and the injected prop is used but never passed, so an agent that renames it or changes its type has no call site to follow. A custom hook is called inside the component, so the line that produces the value sits in the same file as the line that uses it.
Why it happens
React's legacy documentation defines the pattern: "a higher-order component is a function that takes a component and returns a new component." The same page lists what a HOC is expected to do and what it costs:
- It passes unrelated props through so the wrapped component keeps its interface. The wrapper therefore decides part of the props, and the wrapped file only shows the half it declares.
- **It must not be applied inside
render.** Each render would create a new component type, which unmounts and remounts the subtree and loses its state. - Static methods are not copied to the wrapper, and refs point at the outer container unless
React.forwardRefis used. - It suggests a display name such as
WithSubscription(CommentList)so DevTools shows where a component came from.
The current react.dev reference for Component recommends function components over classes and maps class features to hooks, for example useState for state and useContext for this.context. The custom hooks guide says custom hooks "let you share stateful logic but not state itself", and that the use prefix "guarantees that you can always look at a component and know where its state, Effects, and other React features might 'hide'."
That last sentence is the agent-facing argument. With a hook, the dependency is a call expression in the component body: const onSave = useSaveHandler(). A search for useSaveHandler finds the definition and every consumer. With a HOC, the consumer is wrapped at export time (export default withSave(Editor)) and the prop arrives in Editor's props with no visible source. When the HOC is also re-exported through a barrel (AR-02), the file a reader opens first is two hops from the code that supplies the prop. CH-04 demonstrates that case.
Bad and good
An editor component needs a save handler.
// bad: withSave.tsx injects `onSave`; Editor never receives it from a parent
import type { ComponentType } from "react";
export function withSave<P extends { onSave: (doc: Doc) => void }>(
Wrapped: ComponentType<P>,
) {
return function WithSave(props: Omit<P, "onSave">) {
const onSave = (doc: Doc) => api.save(doc);
return <Wrapped {...(props as P)} onSave={onSave} />;
};
}
// Editor.tsx
function Editor(props: { doc: Doc; onSave: (doc: Doc) => void }) {
return <button onClick={() => props.onSave(props.doc)}>Save</button>;
}
export default withSave(Editor);An agent asked to change api.save to take (doc, options) finds onSave used in Editor and passed in withSave. Nothing connects Editor's onSave to its parents, because no parent passes it. A cast like props as P also removes the compiler's check at the one place the contract is enforced.
// good: a hook returns the handler; the component calls it
// useSave.ts
export function useSave() {
return (doc: Doc, options: SaveOptions) => api.save(doc, options);
}
// Editor.tsx
import { useSave } from "./useSave";
export function Editor({ doc }: { doc: Doc }) {
const save = useSave();
return <button onClick={() => save(doc, { draft: false })}>Save</button>;
}Editor now has one prop, doc, and the handler comes from a named call in its own body. Changing useSave's return type produces a type error at every call to save, in the file that has it.
Trade-offs and when a HOC is acceptable
- Hooks only work in function components. The react.dev reference states that hooks are not supported inside class components. A class component you can't convert still needs a wrapper to receive hook values.
- A HOC that adds no props (logging, an error boundary around a subtree, a layout shell) leaves the wrapped component's props unchanged. The visibility problem comes from injected props, and these don't inject any.
- A HOC cannot be replaced by a hook when it controls rendering, for example by choosing whether to render the component at all. Keep it, and give it an explicit prop type on the wrapper's return value (see below).
- A hook costs a call per component. Each caller writes
useSave()itself, which is the visible line this rule wants. - Context and props still work. If the handler is one app-wide value,
useContextis also a visible call in the component, provided the context lives in a named module.
Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| eslint-plugin-react | react/jsx-props-no-spreading (options html, custom, explicitSpread, exceptions; each "ignore" or "enforce", default "enforce") | {...props} spread onto elements. The rule page says it is not useful if spreading is used inside a higher-order component, so it limits spreads in ordinary components rather than HOCs. | 2026-10-30 |
| eslint-plugin-react | react/display-name (in recommended; options ignoreTranspilerName, checkContextObjects) | components with no name, such as an anonymous function returned by a HOC | 2026-10-30 |
| eslint-plugin-react | react/prop-types (in recommended; options ignore, skipUndeclared) | props a component uses but doesn't declare, with TypeScript types counted as declarations | 2026-10-30 |
| tsconfig | strict (includes noImplicitAny, strictFunctionTypes) | untyped wrapper props and loosely typed callbacks | 2026-10-30 |
| eslint-plugin-react-hooks | rules-of-hooks | hooks called in conditions, loops, after early returns, in callbacks, async functions, class methods or at module level. The docs page does not mention higher-order components. | 2026-10-30 |
| typescript-eslint | no-explicit-any (in recommended) | any used to make a wrapper's props compile | 2026-10-30 |
No rule in the pages checked flags a function that takes a component and returns a component. The levers are naming, typed props and review of new wrappers.
Rule block for AGENTS.md / CLAUDE.md
## React components
- Share behaviour with a custom hook (`useThing()`), not a higher-order component that injects props.
- Every prop a component uses is declared in its own props type and passed by a parent, or read through a named hook.
- Don't add `withX(Component)` wrappers. If you meet one, move its logic into a hook and keep the old export until callers are migrated.
- When you change a prop's name or type, search for the old name and update every JSX call site.Edge cases
- **
React.memo(Component)andReact.forwardRef** return wrapped components too. They add no props, so they are fine. - HOCs from a third-party library inject props you can't edit. Wrap them once in your own module, give the result an explicit props type, and import only that module.
- Composed HOCs (
compose(withA, withB)(Component)) stack the problem. Each layer adds props the component file doesn't show. - HOC plus barrel: a barrel that re-exports
withSave(Editor)hides both the wrapper and the prop source. See CH-04 and AR-02. - Typed callbacks: if the injected prop is a function, give it a named type (AR-14) so the contract has one definition.
What LLMGRAPH shows
With a prop-injecting HOC, the call through the prop prints under 'type unknown 1' in what_is_unknown and the wrapped component prints 'passed as a value in 1 place'. With a hook, the call prints certain.
References
- React legacy docs, Higher-Order Components: https://legacy.reactjs.org/docs/higher-order-components.html
- React reference,
Component: https://react.dev/reference/react/Component - React reference, built-in hooks: https://react.dev/reference/react/hooks
- React learn, reusing logic with custom hooks: https://react.dev/learn/reusing-logic-with-custom-hooks
- eslint-plugin-react rules: https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-props-no-spreading.md · https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/display-name.md · https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/prop-types.md
- TSConfig reference: https://www.typescriptlang.org/tsconfig/
- typescript-eslint
no-explicit-any: https://typescript-eslint.io/rules/no-explicit-any/
Changelog
- 2026-10-30: draft 1.
- 2026-10-30: docs checks resolved (rules-of-hooks, Express error handling, Node events, pyright configuration).
Related
- AR-02 Do barrel files hide calls from coding agents?
- AR-13 Why is Any a blind spot for coding agents?
- AR-14 How should I type callbacks so agents can follow them?
- CH-04 Why does a React prop stop arriving after a rename?
Chimeras that use this rule
- CH-04 Why does a React prop stop arriving after a rename?
Last verified