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

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>
  );
}

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>
  );
}

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>
  );
}

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>
  );
}
// You can also use this shorthand version
const handleTodoAdd = () => {
    setTodos(currentTodos => [...currentTodos, inputValue]);
};

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>
  );
}

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>
  );
}

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>
  );
}

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>
  );
}

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>
  );
}

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>
  );
}

Thank you for reading this post 🙏.

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

If you want daily tips, make sure to follow me on X/Twitter.

Reply

or to participate.