DEV Community

Cover image for Beginner mistakes I made while learning React
Bishoy Semsem
Bishoy Semsem

Posted on • Originally published at bishoy-bishai.github.io

Beginner mistakes I made while learning React

The React Rabbit Holes I Fell Into (So You Don't Have To)

When I first started with React, fresh off the jQuery train, it felt like entering a different dimension. Everything was components, state, props, and this mysterious "virtual DOM." It promised a declarative paradise, but my early code often looked more like a spaghetti monster trying to escape a component tree. I’ve found that many of the initial struggles aren't necessarily about complex algorithms, but about fundamentally misunderstanding how React thinks.

I remember one early project, a simple data table with filtering and sorting. My instinct, coming from imperative programming, was to mutate data directly or have components reach up and directly manipulate their parents. This, as you can imagine, led to a cascade of unpredictable updates and state bugs that were a nightmare to debug. It was a painful, yet invaluable, introduction to the "React Way."

Here's the thing: React has a specific mental model. When you fight it, you lose. When you embrace it, magic happens. My goal here isn't to just list common mistakes, but to share the aha! moments I had, and the lessons learned from real-world projects, hoping to fast-track your journey past those early roadblocks.

1. Treating Props Like Local State (and then Wondering Why Nothing Updates)

This is a classic. You receive a prop, say initialValue, and you want to modify it within your component. So, you might do something like this:

// ❌ Don't do this!
function MyInput({ initialValue }: { initialValue: string }) {
  const value = initialValue; // Assigning prop to a local variable
  // ... then trying to change 'value' directly
  // This 'value' won't re-render when initialValue changes from parent
  return <input value={value} onChange={() => { /* mutate value? */ }} />;
}
Enter fullscreen mode Exit fullscreen mode

The issue? Props are immutable, and React components re-render when their state or props change. If initialValue changes, value here is just a local variable copy from the initial render. It doesn't update.

The Fix: Use useState for internal, mutable state.

If a prop is truly just an initial value that your component manages internally, bring it into state:

function MyInput({ initialValue }: { initialValue: string }) {
  const [value, setValue] = React.useState(initialValue);

  // If the parent can change initialValue, you might need to sync it.
  // This is a common pattern for "controlled" vs "uncontrolled" components.
  React.useEffect(() => {
    setValue(initialValue);
  }, [initialValue]); // Re-sync state when initialValue prop changes

  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
Enter fullscreen mode Exit fullscreen mode

Insight: useState creates a persistent, reactive piece of data. Props are inputs to your component. Understanding this distinction is fundamental to React's data flow.

2. The useEffect Abyss: Infinite Loops and Missing Dependencies

Ah, useEffect. It's incredibly powerful but also a source of endless confusion. My early useEffect code often looked something like this:

// ❌ Potential infinite loop!
function DataFetcher({ id }: { id: string }) {
  const [data, setData] = React.useState(null);

  React.useEffect(() => {
    // This effect runs on every render if no dependency array is provided
    // If setData causes a re-render, and this effect runs again,
    // you're in an infinite loop.
    fetch(`/api/data/${id}`).then(res => res.json()).then(setData);
  }); // <-- No dependency array!

  return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}
Enter fullscreen mode Exit fullscreen mode

And then, once I learned about dependency arrays, I'd often misuse them:

// ❌ Missing dependency!
function DataFetcher({ id, authToken }: { id: string; authToken: string }) {
  const [data, setData] = React.useState(null);

  React.useEffect(() => {
    // This effect only runs once on mount due to empty array.
    // If 'id' or 'authToken' changes, the fetch request uses stale values.
    fetch(`/api/data/${id}`, { headers: { Authorization: `Bearer ${authToken}` } })
      .then(res => res.json())
      .then(setData);
  }, []); // <-- Empty array, but depends on `id` and `authToken`!

  return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}
Enter fullscreen mode Exit fullscreen mode

The Fix: Always specify all external values your effect depends on.

React's linter (ESLint with eslint-plugin-react-hooks) is your best friend here.

function DataFetcher({ id, authToken }: { id: string; authToken: string }) {
  const [data, setData] = React.useState(null);
  const [error, setError] = React.useState<string | null>(null);
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    let isMounted = true; // Cleanup flag to prevent state updates on unmounted component
    const fetchData = async () => {
      setLoading(true);
      setError(null);
      try {
        const response = await fetch(`/api/data/${id}`, {
          headers: { Authorization: `Bearer ${authToken}` },
        });
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const result = await response.json();
        if (isMounted) {
          setData(result);
        }
      } catch (e: any) {
        if (isMounted) {
          setError(e.message);
        }
      } finally {
        if (isMounted) {
          setLoading(false);
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false; // Cleanup: Mark component as unmounted
    };
  }, [id, authToken]); // Dependencies: Effect re-runs if 'id' or 'authToken' changes

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{data ? JSON.stringify(data) : 'No data'}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Insight: useEffect fires after every render where its dependencies have changed. If a dependency is missing, your effect might use stale data or simply not re-run when it should. If you include too many (especially objects or functions defined inline that change on every render), it might run too often. Use useCallback and useMemo for functions and objects that are themselves dependencies to stabilize them.

3. Prop Drilling: The Long and Winding Road

You start with a simple parent-child relationship. Then the child needs something from the grandparent. Then the great-grandchild needs it. Soon, you're passing props through layers of components that don't even use those props themselves. This is "prop drilling."

// ❌ Prop Drilling example
function App() {
  const user = { name: "Alice", theme: "dark" };
  return <Toolbar user={user} />;
}

function Toolbar({ user }: { user: { name: string; theme: string } }) {
  return (
    <div>
      <UserInfo user={user} />
      <ThemeSwitcher theme={user.theme} /> {/* Toolbar doesn't directly use theme, just passes it */}
    </div>
  );
}

function UserInfo({ user }: { user: { name: string; theme: string } }) {
  return <span>Welcome, {user.name}</span>;
}

function ThemeSwitcher({ theme }: { theme: string }) {
  return <button>Switch to {theme === 'dark' ? 'light' : 'dark'} mode</button>;
}
Enter fullscreen mode Exit fullscreen mode

In a small example, it's not terrible. In a real-world app with dozens of components, it becomes a maintenance nightmare.

The Fix: Context API or State Management Libraries.

For less frequently updated global data, the React Context API is perfect. For more complex, frequently updated global state, or when you need more robust tools for debugging and asynchronous actions, libraries like Redux, Zustand, or Jotai shine.

Using Context:

interface UserContextType {
  name: string;
  theme: string;
  // Potentially add functions to update theme etc.
}

const UserContext = React.createContext<UserContextType | undefined>(undefined);

function App() {
  const user: UserContextType = { name: "Alice", theme: "dark" };
  return (
    <UserContext.Provider value={user}>
      <Toolbar />
    </UserContext.Provider>
  );
}

function Toolbar() {
  // Toolbar no longer needs to receive 'user' as a prop
  return (
    <div>
      <UserInfo />
      <ThemeSwitcher />
    </div>
  );
}

function UserInfo() {
  const user = React.useContext(UserContext);
  if (!user) return null; // Or throw an error if context is expected
  return <span>Welcome, {user.name}</span>;
}

function ThemeSwitcher() {
  const user = React.useContext(UserContext);
  if (!user) return null;
  return <button>Switch to {user.theme === 'dark' ? 'light' : 'dark'} mode</button>;
}
Enter fullscreen mode Exit fullscreen mode

Insight: Context is great for "theme" or "authenticated user" type data that many components might need. Don't overuse it for all state, as it can make components less reusable and re-renders can be harder to optimize if the context value changes frequently.

4. Forgetting key Props When Rendering Lists

This one is subtle but can cause weird bugs, performance issues, and even destroy component state.

// ❌ Don't use index as key if items can change order or be added/removed!
function ItemList({ items }: { items: string[] }) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={index}>{item}</li> // Problematic if `items` array changes order
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

If the order of items changes, or an item is inserted in the middle, React uses the index as the key to identify which specific <li> corresponds to which data item. If the item at index 0 was "Apple" and now it's "Banana" (because "Orange" was added at index 0), React will simply update the existing <li> component at index 0 from "Apple" to "Banana" instead of re-ordering the actual <li> elements. This might seem fine for simple text, but if those <li> elements held internal state (e.g., an input field's value) or complex child components, their state would be completely messed up.

The Fix: Use a stable, unique identifier for each item.

If your data items have unique IDs from your backend, use those.

interface Item {
  id: string;
  name: string;
}

function ItemList({ items }: { items: Item[] }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li> // Much better!
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

Insight: key props help React efficiently identify, reorder, and reconcile elements in a list. They are not about performance in isolation, but about maintaining the identity of each list item across renders.

Wrapping Up

My journey through React's early challenges taught me that mastering a framework isn't just about syntax; it's about internalizing its core principles. These "mistakes" weren't just errors in my code; they were opportunities to dive deeper into React's philosophy – its declarative nature, its one-way data flow, and its emphasis on component-based architecture.

Don't be afraid to make these mistakes; they're an inevitable part of the learning process. What matters is taking the time to understand why something broke, rather than just patching it. Read the docs, experiment, and don't hesitate to ask questions. Every "bug" is just a puzzle waiting to be solved, leading you closer to becoming a more intuitive and effective React developer. Happy coding!


> 🚀 Read on My Blog

Top comments (0)