- 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