Physical Address

304 North Cardinal St.
Dorchester Center, MA 02124

Common Mistakes React/Next.js Developers Make (And How to Avoid Them)

React and Next.js have revolutionized web development, offering powerful tools for building dynamic, efficient web applications. However, with great power comes… well, you know the rest. Even seasoned developers can fall into traps that lead to suboptimal performance, maintainability issues, or just plain old bugs. In this comprehensive guide, we’ll explore common mistakes that React and Next.js developers often make, and more importantly, how to avoid them.

1. Overusing State

State management is at the heart of React, but it’s easy to fall into the trap of overusing it. This can lead to unnecessary re-renders and complexity in your application.

Mistake 1: Using state for derived values

function ProductList({ products }) {
  const [totalPrice, setTotalPrice] = useState(0);

  useEffect(() => {
    setTotalPrice(products.reduce((sum, product) => sum + product.price, 0));
  }, [products]);

  return (
    <div>
      <h2>Total Price: ${totalPrice}</h2>
      {/* Product list rendering */}
    </div>
  );
}

In this example, totalPrice is unnecessarily stored in state when it can be derived from the products prop.

How to fix it:

function ProductList({ products }) {
  const totalPrice = useMemo(() => 
    products.reduce((sum, product) => sum + product.price, 0),
    [products]
  );

  return (
    <div>
      <h2>Total Price: ${totalPrice}</h2>
      {/* Product list rendering */}
    </div>
  );
}

By using useMemo, we calculate the total price only when the products array changes, avoiding unnecessary recalculations and state updates.

Mistake 2: Using state for form inputs when uncontrolled components suffice

function SimpleForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log(name, email);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

This approach creates state for each form field, which can be overkill for simple forms.

How to fix it:

function SimpleForm() {
  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    console.log(formData.get('name'), formData.get('email'));
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="name" />
      <input type="email" name="email" />
      <button type="submit">Submit</button>
    </form>
  );
}

By using uncontrolled components and FormData, we simplify our component and reduce unnecessary state.

2. Ignoring React’s Lifecycle

Understanding and respecting React’s component lifecycle is crucial for building efficient and bug-free applications.

Mistake 1: Fetching data in the wrong place

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  // This will cause an infinite loop!
  fetch(`/api/users/${userId}`)
    .then(response => response.json())
    .then(data => setUser(data));

  return (
    <div>
      {user ? <h1>{user.name}</h1> : <p>Loading...</p>}
    </div>
  );
}

This code will cause an infinite loop because the fetch is called on every render, which triggers a state update, causing another render.

How to fix it:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(response => response.json())
      .then(data => setUser(data));
  }, [userId]);

  return (
    <div>
      {user ? <h1>{user.name}</h1> : <p>Loading...</p>}
    </div>
  );
}

By using useEffect, we ensure that the fetch only happens when the component mounts or when userId changes.

Mistake 2: Not cleaning up side effects

function LiveData() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const ws = new WebSocket('wss://example.com');
    ws.onmessage = (event) => {
      setData(JSON.parse(event.data));
    };
  }, []);

  return <div>{data ? <p>{data.message}</p> : <p>Waiting for data...</p>}</div>;
}

This component opens a WebSocket connection but doesn’t close it when the component unmounts, potentially leading to memory leaks.

How to fix it:

function LiveData() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const ws = new WebSocket('wss://example.com');
    ws.onmessage = (event) => {
      setData(JSON.parse(event.data));
    };

    return () => {
      ws.close();
    };
  }, []);

  return <div>{data ? <p>{data.message}</p> : <p>Waiting for data...</p>}</div>;
}

By returning a cleanup function from useEffect, we ensure that the WebSocket connection is properly closed when the component unmounts.

3. Misunderstanding the Virtual DOM

React’s Virtual DOM is a powerful optimization, but misunderstanding how it works can lead to performance issues.

Mistake 1: Unnecessarily recreating complex objects in render

function ProductCard({ product }) {
  return (
    <div style={{ 
      padding: '10px', 
      margin: '5px', 
      border: '1px solid #ccc',
      borderRadius: '5px'
    }}>
      <h2>{product.name}</h2>
      <p>{product.description}</p>
    </div>
  );
}

In this example, a new style object is created on every render, which can cause unnecessary re-renders of child components.

How to fix it:

const cardStyle = {
  padding: '10px',
  margin: '5px',
  border: '1px solid #ccc',
  borderRadius: '5px'
};

function ProductCard({ product }) {
  return (
    <div style={cardStyle}>
      <h2>{product.name}</h2>
      <p>{product.description}</p>
    </div>
  );
}

By defining the style object outside the component, we ensure it’s not recreated on every render.

Mistake 2: Using index as key in lists

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>{todo.text}</li>
      ))}
    </ul>
  );
}

Using the array index as a key can lead to unexpected behavior when the list items can change order or be deleted.

How to fix it:

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

By using a unique identifier from the data itself, we help React efficiently track changes in the list.

4. Prop Drilling

As applications grow, passing props through multiple levels of components can become unwieldy and hard to maintain.

Mistake: Passing props through many levels

function App({ user }) {
  return (
    <div>
      <Header user={user} />
      <Main user={user} />
      <Footer user={user} />
    </div>
  );
}

function Header({ user }) {
  return <NavBar user={user} />;
}

function NavBar({ user }) {
  return <UserDropdown user={user} />;
}

function UserDropdown({ user }) {
  return <span>{user.name}</span>;
}

This approach can make components tightly coupled and harder to reuse.

How to fix it:

Using React’s Context API can help avoid prop drilling:

const UserContext = React.createContext();

function App({ user }) {
  return (
    <UserContext.Provider value={user}>
      <div>
        <Header />
        <Main />
        <Footer />
      </div>
    </UserContext.Provider>
  );
}

function Header() {
  return <NavBar />;
}

function NavBar() {
  return <UserDropdown />;
}

function UserDropdown() {
  const user = useContext(UserContext);
  return <span>{user.name}</span>;
}

By using Context, we make the user data available to any component that needs it without passing it explicitly through props.

5. Misusing useEffect

The useEffect hook is powerful but easy to misuse, leading to unexpected behavior or performance issues.

Mistake 1: Incorrect dependency array

function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetchResults(query).then(setResults);
  }, []); // Empty dependency array!

  return (
    <ul>
      {results.map(result => (
        <li key={result.id}>{result.title}</li>
      ))}
    </ul>
  );
}

This effect will only run once, on mount, and won’t update when the query changes.

How to fix it:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetchResults(query).then(setResults);
  }, [query]); // Depend on query

  return (
    <ul>
      {results.map(result => (
        <li key={result.id}>{result.title}</li>
      ))}
    </ul>
  );
}

By adding query to the dependency array, we ensure the effect runs whenever the query changes.

Mistake 2: Using useEffect for data fetching without cleanup

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  if (!user) return <p>Loading...</p>;

  return <h1>{user.name}</h1>;
}

If userId changes rapidly, this can lead to race conditions where an older fetch resolves after a newer one.

How to fix it:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let isCurrent = true;
    setUser(null);
    fetchUser(userId).then(userData => {
      if (isCurrent) setUser(userData);
    });
    return () => {
      isCurrent = false;
    };
  }, [userId]);

  if (!user) return <p>Loading...</p>;

  return <h1>{user.name}</h1>;
}

By using a flag and cleanup function, we prevent setting state for outdated requests.

6. Neglecting Performance Optimization

As applications grow, performance can suffer if proper optimization techniques aren’t employed.

Mistake 1: Not memoizing expensive computations

function ProductList({ products }) {
  const sortedProducts = products
    .slice()
    .sort((a, b) => b.price - a.price);

  return (
    <ul>
      {sortedProducts.map(product => (
        <li key={product.id}>{product.name} - ${product.price}</li>
      ))}
    </ul>
  );
}

This component will re-sort the products on every render, even if the products haven’t changed.

How to fix it:

function ProductList({ products }) {
  const sortedProducts = products
    .slice()
    .sort((a, b) => b.price - a.price);

  return (
    <ul>
      {sortedProducts.map(product => (
        <li key={product.id}>{product.name} - ${product.price}</li>
      ))}
    </ul>
  );
}

By using useMemo, we ensure the sorting only happens when the products array changes.

Mistake 2: Not using React.memo for pure functional components

function ExpensiveComponent({ data }) {
  // Some expensive rendering logic
  return <div>{/* Rendered content */}</div>;
}

function ParentComponent({ data, otherProp }) {
  return (
    <div>
      <ExpensiveComponent data={data} />
      <SomeOtherComponent prop={otherProp} />
    </div>
  );
}

ExpensiveComponent will re-render every time ParentComponent re-renders, even if data hasn’t changed.

How to fix it:

const MemoizedExpensiveComponent = React.memo(function ExpensiveComponent({ data }) {
  // Some expensive rendering logic
  return <div>{/* Rendered content */}</div>;
});

function ParentComponent({ data, otherProp }) {
  return (
    <div>
      <MemoizedExpensiveComponent data={data} />
      <SomeOtherComponent prop={otherProp} />
    </div>
  );
}

By wrapping ExpensiveComponent in React.memo, we ensure it only re-renders when its props change.

7. Improper Error Handling

Proper error handling is crucial for creating robust applications and providing a good user experience.

Mistake 1: Not using error boundaries

function App() {
  return (
    <div>
      <Header />
      <Main />
      <Footer />
    </div>
  );
}

function Main() {
  // This could throw an error
  const data = JSON.parse(localStorage.getItem('data'));
  return <div>{data.content}</div>;
}

If JSON.parse throws an error, it will crash the entire application.

How to fix it:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.log(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}

function App() {
  return (
    <div>
      <Header />
      <ErrorBoundary>
        <Main />
      </ErrorBoundary>
      <Footer />
    </div>
  );
}

function Main() {
  // This could throw an error
  const data = JSON.parse(localStorage.getItem('data'));
  return <div>{data.content}</div>;
}

By wrapping Main in an ErrorBoundary, we prevent the entire app from crashing if an error occurs in Main.

Mistake 2: Not handling asynchronous errors

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  if (!user) return <p>Loading...</p>;

  return <h1>{user.name}</h1>;
}

If fetchUser rejects, this component will hang in the loading state forever.

How to fix it:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchUser(userId)
      .then(setUser)
      .catch(error => {
        console.error('Failed to fetch user:', error);
        setError('Failed to load user data');
      });
  }, [userId]);

  if (error) return <p>Error: {error}</p>;
  if (!user) return <p>Loading...</p>;

  return <h1>{user.name}</h1>;
}

By catching and handling the error, we can display an error message to the user instead of leaving them in a perpetual loading state.

8. Ignoring Accessibility

Accessibility is often overlooked, but it’s crucial for creating inclusive web applications.

Mistake 1: Using divs for interactive elements

function Button({ onClick, children }) {
  return (
    <div onClick={onClick}>
      {children}
    </div>
  );
}

This “button” isn’t keyboard accessible and doesn’t communicate its role to assistive technologies.

How to fix it:

function Button({ onClick, children }) {
  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
}

Using the semantic <button> element ensures proper keyboard accessibility and role communication.

Mistake 2: Not providing alternative text for images

function ProductImage({ src, name }) {
  return <img src={src} />;
}

Screen readers can’t convey the content of this image to users.

How to fix it:

function ProductImage({ src, name }) {
  return <img src={src} alt={name} />;
}

Providing descriptive alt text helps screen reader users understand the content of images.

9. Common Mistakes to Avoid in Next.js 14 with App Router

Next.js 14 introduced the App Router, bringing significant changes to how we structure and build applications. While these changes offer powerful new capabilities, they also come with potential pitfalls. In this post, we’ll explore nine common mistakes developers make when working with Next.js 14 and the App Router, along with solutions to avoid them.

Fetching Data on the Client When It Could Be Fetched Server-Side

One of the most common mistakes is unnecessarily fetching data on the client side. With the App Router, we can leverage server components to fetch data more efficiently.

Mistake: Using useEffect to fetch data in a client component, even for content that rarely changes.

// app/posts/[slug]/page.js
'use client';

import { useState, useEffect } from 'react';

export default function BlogPost({ params }) {
  const [post, setPost] = useState(null);

  useEffect(() => {
    fetch(`/api/posts/${params.slug}`)
      .then(res => res.json())
      .then(setPost);
  }, [params.slug]);

  if (!post) return <p>Loading...</p>;

  return <article>{/* Render post content */}</article>;
}

This approach fetches data on every page load, even if the content rarely changes.

Solution: Utilize server components and the fetch function with revalidation. This approach improves performance and SEO by fetching data on the server and optionally caching it.

// app/posts/[slug]/page.js
async function getPost(slug) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, { next: { revalidate: 3600 } });
  if (!res.ok) throw new Error('Failed to fetch post');
  return res.json();
}

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);
  return <article>{/* Render post content */}</article>;
}

10. Overlooking TypeScript

While not a mistake per se, not using TypeScript can lead to type-related bugs that could have been caught early in development.

Mistake: Not leveraging TypeScript for prop type checking

function UserProfile({ user }) {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
      <p>Age: {user.age}</p>
    </div>
  );
}

This component assumes user has certain properties, but there’s no guarantee.

How to fix it:

interface User {
  name: string;
  email: string;
  age: number;
}

function UserProfile({ user }: { user: User }) {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
      <p>Age: {user.age}</p>
    </div>
  );
}

By defining the User interface and using it to type the user prop, we catch type-related errors at compile-time.

Conclusion

React and Next.js are powerful tools, but with great power comes great responsibility. By avoiding these common mistakes, you’ll create more robust, performant, and maintainable applications. Remember, the best way to avoid these pitfalls is through practice, code reviews, and staying up-to-date with best practices in the React and Next.js ecosystems.

Happy coding, and may your components always render flawlessly! 🚀

Leave a Reply

Your email address will not be published. Required fields are marked *