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.
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.
Top comments (0)