DEV Community

Cover image for React: 5 Small (Yet Easily Fixable) Mistakes Junior Frontend Developers Make With React State
Ndeye Fatou Diop
Ndeye Fatou Diop

Posted on • Edited on • Originally published at frontendjoy.com

4 1 1 1

React: 5 Small (Yet Easily Fixable) Mistakes Junior Frontend Developers Make With React State

I have reviewed more than 1,000 front-end pull requests.

Like many junior developers, I made some common mistakes with React state when I started.

If you're in the same boat, here are 5 small mistakes you can quickly fix to use state properly in React:

Mistake #1: Having an invalid state format

Your state should always be valid.

When we take a snapshot of your state, it should reflect a valid state of the world. This stops problems like:

  • Having too verbose code
  • Forgetting to reset some state properly
  • Displaying the wrong UI under certain conditions

❌ Bad: We shouldn't have error, data, and isLoading separate. Nothing prevents situations where error is present, data is present, and isLoading is still set to true.

import { useState, useEffect } from "react";

export default function App() {
  const [error, setError] = useState();
  const [data, setData] = useState();
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    // We have more work to do to update the state
    const fetchCatFacts = async () => {
      try {
        setIsLoading(true);
        setError(undefined);
        const response = await fetch("https://cat-fact.herokuapp.com/facts");
        const facts = await response.json();
        setData(facts);
      } catch (error) {
        setError(error);
      } finally {
        setIsLoading(false);
      }
    };

    fetchCatFacts();
  }, []);

  return (
    <div className="App">
      <h1>Cat Facts</h1>
      {isLoading ? (
        <p>Loading…</p>
      ) : error != null ? (
        <p>Failed to load facts</p>
      ) : data != null ? (
        <ul>
          {data.map((d) => (
            <li key={d._id}>{d.text}</li>
          ))}
        </ul>
      ) : (
        <p>BAD: This should never happen but can if the state is not updated correctly </p>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

✅ Good: We have a single state data that can only be in 4 states (neverLoaded, loading, error, loaded).

export default function App() {
  const [data, setData] = useState({ type: "neverLoaded" });

  useEffect(() => {
    // The state is easier to update
    const fetchCatFacts = async () => {
      try {
        setData({ type: "loading" });
        const response = await fetch("https://cat-fact.herokuapp.com/facts");
        const facts = await response.json();
        setData({ type: "loaded", data: facts });
      } catch (error) {
        setData({ type: "error", error });
      }
    };

    fetchCatFacts();
  }, []);

  return (
    <div className="App">
      <h1>Cat Facts</h1>
      {(() => {
        // We can handle all states properly
        switch (data.type) {
          case "neverLoaded":
          case "loading":
            return <p>Loading…</p>;
          case "error":
            return <p>Failed to load facts</p>;
          case "loaded":
            return (
              <ul>
                {data.data.map((d) => (
                  <li key={d._id}>{d.text}</li>
                ))}
              </ul>
            );
        }
      })()}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Mistake #2: Mutating the state vs. creating a new one

Never mutate state!

99% of the time, that's why you don't see your state changes happening.

Instead, make new objects/arrays every time so React knows the state is different and can update your app.

❌ Bad: This code won't work because todos is being mutated instead of copied. So, React won't update because it sees the same object being used.

import { useState } from "react";

export default function App() {
  const [inputValue, setInputValue] = useState("");
  const [todos, setTodos] = useState([]);

  const handleTodoAdd = () => {
    todos.push(inputValue);
    // This won't work since we are mutating `todos` and not creating a new object
    // So, from React perspective, nothing changed
    setTodos(todos);
  };

  return (
    <div className="App">
      <input
        value={inputValue}
        placeholder="Enter todo here"
        onChange={(e) => setInputValue(e.target.value)}
      />
      <button onClick={handleTodoAdd}>Add</button>
      <ul>
        {todos.map((todo, idx) => (
          <li key={idx}>{todo}</li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

✅ Good: The code below will work since newTodos != todos.

import { useState } from "react";

export default function App() {
  const [inputValue, setInputValue] = useState("");
  const [todos, setTodos] = useState([]);

  const handleTodoAdd = () => {
    // We create a completely new object
    const newTodos = [...todos];
    newTodos.push(inputValue);
    // This will now properly
    setTodos(newTodos);
  };

  return (
    <div className="App">
      <input
        value={inputValue}
        placeholder="Enter todo here"
        onChange={(e) => setInputValue(e.target.value)}
      />
      <button onClick={handleTodoAdd}>Add</button>
      <ul>
        {todos.map((todo, idx) => (
          <li key={idx}>{todo}</li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
// You can also use this shorthand version
const handleTodoAdd = () => {
    setTodos(currentTodos => [...currentTodos, inputValue]);
};
Enter fullscreen mode Exit fullscreen mode

Tip 💡: If you're confused about why newTodos != todos, check the difference between primitive types and reference types in JavaScript (article).

Mistake #3: Having too much state

Keep your state minimal.

The more state you have:

  • The harder the code is to debug since it can change for various reasons
  • The harder it is to keep everything in sync because you must remember to update all the states properly
  • The slower it might get because updating state can happen one after another, causing multiple updates

❌ Bad: name shouldn't be in the state since it can be derived from firstName and lastName.

export default function App() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLasName] = useState("");
  const [name, setName] = useState("");

  useEffect(() => {
    setName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);

  return (
    <form className="App">
      {name.trim() !== "" && <div>Hello {name}</div>}
      <div className="input">
        <label htmlFor="firstName">First name</label>{" "}
        <input
          id="firstName"
          value={firstName}
          onChange={(e) => setFirstName(e.target.value)}
        />
      </div>
      <div className="input">
        <label htmlFor="lastName">Last name</label>{" "}
        <input
          id="lastName"
          value={lastName}
          onChange={(e) => setLasName(e.target.value)}
        />
      </div>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

✅ Good: We dropped the name state and just derived it. The code is faster, easier to understand, and more concise.

export default function App() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLasName] = useState("");
  const name = `${firstName} ${lastName}`;

  return (
    <form className="App">
      {name.trim() !== "" && <div>Hello {name}</div>}
      <div className="input">
        <label htmlFor="firstName">First name</label>{" "}
        <input
          id="firstName"
          value={firstName}
          onChange={(e) => setFirstName(e.target.value)}
        />
      </div>
      <div className="input">
        <label htmlFor="lastName">Last name</label>{" "}
        <input
          id="lastName"
          value={lastName}
          onChange={(e) => setLasName(e.target.value)}
        />
      </div>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Mistake #4: Not leveraging useReducer enough

useReducer is great for a couple of reasons:

  • You get a single place (the reducer) to track all state changes.
  • Once created, the dispatch object stays the same. This means components that don't use state won't need to update if they're just dispatching actions.
  • It's easily expandable; you can add more actions, and so on.

❌ Bad: With lots of logic already, adding methods like bulk complete and edits will only make the code messier.

export default function App() {
  const [inputValue, setInputValue] = useState("");
  const [todos, setTodos] = useState([]);

  const handleTodoAdd = () => {
    setTodos((t) => [
      ...t,
      { id: Math.random(), todo: inputValue, completed: false },
    ]);
    setInputValue("");
  };

  const handleTodoRemove = (id) => {
    setTodos((t) => t.filter((t) => t.id !== id));
  };

  const handleTodoToggle = (id) => {
    setTodos((t) =>
      t.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
    );
  };

  const handleTodosClear = (id) => {
    setTodos([]);
  };

  return (
    <div className="App">
      <input
        value={inputValue}
        placeholder="Enter todo here"
        onChange={(e) => setInputValue(e.target.value)}
      />
      <button onClick={handleTodoAdd}>Add</button>
      <ul>
        {todos.map(({ todo, id }, idx) => (
          <li key={idx}>
            <input
              type="checkbox"
              value={todo.completed}
              onClick={() => handleTodoToggle(id)}
            />
            {todo} <button onClick={() => handleTodoRemove(id)}>x</button>
          </li>
        ))}
      </ul>
      {todos.length > 0 && <button onClick={handleTodosClear}>Clear</button>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

✅ Good: We encapsulate all the logic inside the reducer

import { useState, useReducer } from "react";

// All the state update logic is done inside the reducer
const todoAppReducer = (state, action) => {
  switch (action.type) {
    case "addTodo":
      return [...state, { id: Math.random(), todo: action.payload }];
    case "removeTodo":
      return state.filter((t) => t.id !== action.payload.id);
    case "toggleTodo":
      return state.map((t) =>
        t.id === action.payload.id ? { ...t, completed: !t.completed } : t
      );
    case "clearTodos":
      return [];
    default:
      return state;
  }
};

export default function App() {
  const [inputValue, setInputValue] = useState("");
  const [todos, dispatch] = useReducer(todoAppReducer, []);

  const handleTodoAdd = () => {
    dispatch({ type: "addTodo", payload: inputValue });
    setInputValue("");
  };

  const handleTodoRemove = (id) => {
    dispatch({ type: "removeTodo", payload: { id } });
  };

  const handleTodoToggle = (id) => {
    dispatch({ type: "toggleTodo", payload: { id } });
  };

  const handleTodosClear = (id) => {
    dispatch({ type: "clearTodos" });
  };

  return (
    <div className="App">
      <input
        value={inputValue}
        placeholder="Enter todo here"
        onChange={(e) => setInputValue(e.target.value)}
      />
      <button onClick={handleTodoAdd}>Add</button>
      <ul>
        {todos.map(({ todo, id }, idx) => (
          <li key={idx}>
            <input
              type="checkbox"
              value={todo.completed}
              onClick={() => handleTodoToggle(id)}
            />
            {todo} <button onClick={() => handleTodoRemove(id)}>x</button>
          </li>
        ))}
      </ul>
      {todos.length > 0 && <button onClick={handleTodosClear}>Clear</button>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Mistake #5: Accessing the new state before it's ready

Hands up if you're a junior dev and haven't made this mistake 🖐️.

In React, state updates don't happen immediately. So, if you want to use the new state, you have to:

  • Access it when you're setting it
  • Or, wait until the state has been updated

❌ Bad: The code below won't work properly because when we log counter, it is still equal to the current value and not counter + 1.

import { useState } from "react";

export default function App() {
  const [counter, setCounter] = useState(0);

  const increment = () => {
    setCounter(counter + 1);
    // `counter` is equal to the current value and not the next one
    alert(`This will be the next counter value: ${counter}`);
  };

  return (
    <div className="App">
      <span className="counter">{counter}</span>
      <button onClick={increment}>Increment</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

✅ Good: We correctly log the next counter value.

import { useState } from "react";

export default function App() {
  const [counter, setCounter] = useState(0);

  const increment = () => {
    const nextCounter = counter + 1;
    setCounter(nextCounter);
    alert(`This will be the next counter value: ${nextCounter}`);
  };

  return (
    <div className="App">
      <span className="counter">{counter}</span>
      <button onClick={increment}>Increment</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Thank you for reading this post 🙏.

Leave a comment 📩 to share a mistake you made with React state and how you overcame it.

And Don't forget to Drop a "💖🦄🔥".

If you like articles like this, join my FREE newsletter, FrontendJoy.

If you want daily tips, find me on X/Twitter.

Image of Timescale

🚀 pgai Vectorizer: SQLAlchemy and LiteLLM Make Vector Search Simple

We built pgai Vectorizer to simplify embedding management for AI applications—without needing a separate database or complex infrastructure. Since launch, developers have created over 3,000 vectorizers on Timescale Cloud, with many more self-hosted.

Read more

Top comments (0)

Postmark Image

Speedy emails, satisfied customers

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up