React is one of the most popular front-end frameworks, widely adopted for large-scale enterprise applications and consumer-facing websites. It’s powerful and flexible, but its popularity means many inexperienced developers pick it up quickly without always following best practices. This post highlights common bad techniques and the corresponding good practices that help keep applications maintainable, testable, scalable and performant.
Bad practices and smarter alternatives
1. Overusing state in components
Bad practice
A common mistake in React projects is pushing too much state down into components that shouldn’t own it. This often results in presentation (or “dumb”) components being tightly coupled to application state, business logic, or data-fetching. These components lose their reusability and become harder to test or adapt to different contexts.
// ❌ Bad: Presentation component tied to state and business logicfunction ProductCard() { const [inCart, setInCart] = React.useState(false); const handleAddToCart = () => { setInCart(true); // some business logic here... console.log("Added to cart"); }; return ( <div className="product-card"> <h2>Product Name</h2> <button onClick={handleAddToCart}> {inCart ? "In Cart" : "Add to Cart"} </button> </div> );}
This ProductCard is not just rendering a product — it’s also managing cart state and running side effects. That makes it harder to reuse elsewhere, harder to test in isolation, and forces design changes to be tangled with business logic.
Better alternative
Separate presentation from stateful logic. Presentation components should ideally be “application-agnostic” — their sole job is to render UI based on props. State and business rules can be lifted to container components or hooks.
// ✅ Good: Presentation component (stateless)function ProductCard({ name, inCart, onAddToCart }) { return ( <div className="product-card"> <h2>{name}</h2> <button onClick={onAddToCart}> {inCart ? "In Cart" : "Add to Cart"} </button> </div> );}// ✅ Good: Container manages state & logicfunction ProductContainer({ product }) { const [inCart, setInCart] = React.useState(false); const handleAddToCart = () => { setInCart(true); // business logic here, e.g. update global store }; return ( <ProductCard name={product.name} inCart={inCart} onAddToCart={handleAddToCart} /> );}
Why it matters
- Maintainability: Presentation components are easier to reason about and update when design changes.
- Versatility: Stateless components can be reused across contexts (e.g. marketing pages, checkout flows).
- Testability: Presentation components can be snapshot or unit tested in isolation without worrying about state.
- Scalability: Business logic is easier to extend or replace when not entangled with UI rendering.
2. Not memoizing expensive computations or components
Bad practice
React’s re-render cycle means that functions, computations, or components are recalculated every time a parent component updates. If you don’t memoize expensive operations, you may end up repeatedly performing costly calculations or unnecessarily re-rendering child components. This can degrade performance as your app scales.
// ❌ Bad: Expensive calculation runs on every renderfunction Stats({ items }) { // imagine items is a large array const total = items.reduce((acc, item) => acc + item.value, 0); return <div>Total: {total}</div>;}
Here, even if items hasn’t changed, the reduce method runs every render. For large arrays, this creates unnecessary work.
Similarly, components passed as children without memoization can re-render unnecessarily:
// ❌ Bad: Child re-renders every time parent re-rendersfunction Parent({ items }) { return ( <div> <Stats items={items} /> {/* recalculates every time */} </div> );}
Better alternative
Use useMemo for expensive calculations and React.memo for components to prevent unnecessary re-renders when inputs haven’t changed.
// ✅ Good: Expensive calculation memoizedfunction Stats({ items }) { const total = React.useMemo(() => { return items.reduce((acc, item) => acc + item.value, 0); }, [items]); return <div>Total: {total}</div>;}// ✅ Good: Component memoized to avoid unnecessary re-rendersconst Stats = React.memo(function Stats({ items }) { const total = React.useMemo(() => { return items.reduce((acc, item) => acc + item.value, 0); }, [items]); return <div>Total: {total}</div>;});
Why it matters
- Performance: Prevents recalculations or re-renders when inputs are unchanged.
- Efficiency: Especially important in lists, dashboards, or data-heavy views.
- Testability: Presentation components can be snapshot or unit tested in isolation without worrying about state.
- Usability: Smoother interactions and less jank in the UI.
3. Inline anonymous functions in JSX
Bad practice
It’s tempting to define event handlers directly inside JSX. While this works, it means a new function is created every render. In isolation, that cost is small — but in larger applications with many interactive elements, it can cause unnecessary re-renders and make performance worse.
// ❌ Bad: Inline anonymous function re-created on each renderfunction TodoList({ todos, onToggle }) { return ( <ul> {todos.map((todo) => ( <li key={todo.id}> <input type="checkbox" checked={todo.completed} onChange={() => onToggle(todo.id)} // new function each time /> {todo.text} </li> ))} </ul> );}
Better alternative
Define handlers outside of JSX. If the handler depends on props or state, use useCallback to memoize it and ensure stability across renders.
// ✅ Good: Handler defined and memoizedfunction TodoList({ todos, onToggle }) { const handleToggle = React.useCallback( (e) => { const id = e.currentTarget.dataset.id; onToggle(id); }, [onToggle] ); return ( <ul> {todos.map((todo) => ( <li key={todo.id}> <input type="checkbox" data-id={todo.id} checked={todo.completed} onChange={handleToggle} // stable reference /> {todo.text} </li> ))} </ul> );}
Why it matters
- Performance: Fewer unnecessary re-renders; Stable handlers play nicely with React.memo and dependency arrays.
- Predictability: Easier to reason about updates and performance.
- Scaleability: Especially important in large lists or interactive UIs.
4. Improper useEffect usage
Bad practice
It’s all too common to introduce side effects inside useEffect without proper cleanup or dependency control, leading to unintended behaviors like duplicated listeners or state updates. Even when it seems harmless, those invisible bugs can crop up later, causing performance glitches or memory leaks.
// ❌ Bad: No cleanup, multiple listeners pile upfunction WindowWidth() { const [width, setWidth] = React.useState(window.innerWidth); React.useEffect(() => { const handleResize = () => setWidth(window.innerWidth); window.addEventListener("resize", handleResize); // no cleanup provided! }); return <div>Width: {width}</div>;}
Every render adds another resize listener, so setWidth is called multiple times per resize. When the component unmounts, the listener is still attached, wasting memory and potentially causing errors.
Better alternative
Always return a cleanup function from useEffect to remove event listeners, cancel subscriptions, or clear timers when the component unmounts or before the effect re-runs.
// ✅ Good: Cleanup removes listener properlyfunction WindowWidth() { const [width, setWidth] = React.useState(window.innerWidth); React.useEffect(() => { const handleResize = () => setWidth(window.innerWidth); window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); }; }, []); // run once, clean up on unmount return <div>Width: {width}</div>;}
Why it matters
- Performance: Prevents memory leaks by ensuring resources are released.
- Efficiency: Avoids duplicated work from multiple lingering listeners.
- Predictability: Improves reliability by ensuring effects don’t survive longer than intended as they mount, update, and unmount.
5. Mixing concerns in components
Bad practice
It’s common to see React components grow into “God components” — handling UI rendering, business logic, data fetching, and state management all in one place. While this may feel convenient at first, the result is bloated, hard-to-test code that quickly becomes unmaintainable.
// ❌ Bad: Component doing everythingfunction UserProfile() { const [user, setUser] = React.useState(null); const [loading, setLoading] = React.useState(true); React.useEffect(() => { async function fetchUser() { const res = await fetch("/api/user"); const data = await res.json(); setUser(data); setLoading(false); } fetchUser(); }, []); if (loading) return <p>Loading...</p>; return ( <div> <h2>{user.name}</h2> <p>Email: {user.email}</p> <p>Location: {user.location}</p> </div> );}
Here, the component fetches user data, manages state, and renders the UI. That’s three separate concerns tangled together.
Better alternative
Separate the concerns:
- Extract data fetching into a custom hook.
- Keep the component itself focused on presentation.
// ✅ Good: Custom hook for data fetchingfunction useUser() { const [user, setUser] = React.useState(null); const [loading, setLoading] = React.useState(true); React.useEffect(() => { async function fetchUser() { const res = await fetch("/api/user"); const data = await res.json(); setUser(data); setLoading(false); } fetchUser(); }, []); return { user, loading };}// ✅ Good: Component focused on renderingfunction UserProfile() { const { user, loading } = useUser(); if (loading) return <p>Loading...</p>; return ( <div> <h2>{user.name}</h2> <p>Email: {user.email}</p> <p>Location: {user.location}</p> </div> );}
Or a better well-known React pattern is to split components into Container components (concerned with data, state, and logic) and Presentational components (concerned only with rendering UI based on props).
// ✅ Container: Handles fetching and statefunction useUser() { const [user, setUser] = React.useState(null); const [loading, setLoading] = React.useState(true); React.useEffect(() => { async function fetchUser() { const res = await fetch("/api/user"); const data = await res.json(); setUser(data); setLoading(false); } fetchUser(); }, []); return { user, loading };}// ✅ Presentational: Only cares about renderingfunction UserProfileView({ user }) { return ( <div> <h2>{user.name}</h2> <p>Email: {user.email}</p> <p>Location: {user.location}</p> </div> );}// ✅ Container + Presentational togetherfunction UserProfile() { const { user, loading } = useUser(); if (loading) return <p>Loading...</p>; return <UserProfileView user={user} />;}
Why it matters
- Maintainability: Each part of the code has a single responsibility.
- Reusability: Hooks encapsulate logic that can be reused across components and presentational components can be dropped into other contexts without modification.
- Testability: Logic (container) and UI (presentational) can be tested independently.
- Scalability: Large applications benefit from this clear separation, keeping components lean and predictable.
6. Defining functions within components
Bad practice
It’s common to see helper functions defined directly inside a component, even when they don’t depend on that component’s state or props. While harmless in simple cases, this causes the function to be redefined on every render, increasing overhead and making it harder to reuse or test in isolation.
// ❌ Bad: Helper defined inside component, re-created every renderfunction UserList({ users }) { const formatName = (user) => `${user.firstName} ${user.lastName}`; return ( <ul> {users.map((u) => ( <li key={u.id}>{formatName(u)}</li> ))} </ul> );}
Even though formatName has nothing to do with component state, it’s recreated every time UserList renders. It can’t be easily tested or reused outside this component.
Better alternative
Elevate non-state-dependent functions to module or global scope. This ensures they are defined only once, are easy to test, and can be shared across components.
// utils.jsexport function formatName(user) { return `${user.firstName} ${user.lastName}`;}// ✅ Good: Import and use helper in componentimport { formatName } from "./utils";function UserList({ users }) { return ( <ul> {users.map((u) => ( <li key={u.id}>{formatName(u)}</li> ))} </ul> );}
Why it matters
- Performance: Avoids unnecessary re-creation of functions on each render.
- Testability: Helpers can be tested in isolation outside of React.
- Reusability: Shared utilities encourage consistency across the codebase.
- Clarity: Keeps components focused on rendering rather than utility logic.
And let’s not forget accessibility
Bad practice
Custom components often define their own props but forget to extend the native HTML element props. This means accessibility-related props (aria-*, role, tabIndex, etc.) either get dropped or trigger TypeScript errors, discouraging developers from using them.
// ❌ Bad: Props defined manually, no accessibility supporttype IconButtonProps = { icon: React.ReactNode; onClick: () => void;};function IconButton({ icon, onClick }: IconButtonProps) { return ( <button onClick={onClick}> <span>{icon}</span> </button> );}// Usage -- TS error, aria-label not allowed<IconButton icon="🔍" onClick={handleSearch} aria-label="Search" />;
Better alternative
For inputs, extend React.InputHTMLAttributes<HTMLInputElement>.
// ✅ Good: Extend input attributestype TextInputProps = { label: string;} & React.InputHTMLAttributes<HTMLInputElement>;function TextInput({ label, id, ...rest }: TextInputProps) { return ( <div> <label htmlFor={id}>{label}</label> <input id={id} {...rest} /> </div> );}// Usage -- TS recognises accessibility props<TextInput id="email" label="Email" type="email" placeholder="your@email.com" aria-describedby="email-help" required/><p id="email-help">We'll never share your email with anyone else.</p>
Why it matters
- Type safety: TypeScript enforces valid HTML + accessibility props.
- Developer experience: IDEs offer autocompletion for
aria-*attributes. - Consistency: Custom components act like their native equivalents.
- Inclusion by default: Accessibility props don’t get “lost” or blocked by typing mistakes.
👉 General pattern
React.ButtonHTMLAttributes<HTMLButtonElement>for buttonsReact.InputHTMLAttributes<HTMLInputElement>for inputsReact.AnchorHTMLAttributes<HTMLAnchorElement>for linksReact.HTMLAttributes<HTMLDivElement>as a fallback for generic elements
Finally
Writing React code that works is the easy part. The real challenge is writing React that scales; code that stays maintainable, performs efficiently, and supports accessibility from the start. By avoiding the common pitfalls we’ve covered and adopting the better alternatives, you set yourself (and your team) up for smoother development and a better end-user experience.
These practices aren’t just nice-to-haves; they’re the difference between code that survives a prototype and code that supports a production system for years.
Looking for developers that build robust, accessible high-performance React apps? Reach out