~/nyuma.dev

How to make useState in React not suck's cover image

How to make useState in React not suck

A lot of engineers use React's useState incorrectly, let's address it

8 mins read

Hey nerds. Look at the cover. You see that? Stop that.

On this Page

We all know useState is one of React's most important hooks. You manage pretty much all of your component's state with it. At its core, it's just a plain old JavaScript object. Look, here's a simple state:

1const [x, setX] = useState(0);

Now, in POJO form:

1const state = {
2 x: 0,
3};

Simple enough!

But then, this is where the problems begin. A lot of engineers, especially those onboarding to a web development era led by generative AI tools, start using useState incorrectly.

In this random thought, we'll be going over a few common patterns to write state management like a pro.

Think back to the POJO example. You have a x and a setter, setX.

Now, let's say you want to add a y and a setter, setY.

You might be tempted to do this:

1const [x, setX] = useState(0);
2const [y, setY] = useState(0);

But this is bad. Why?

Well, let's say you want to update both x and y at the same time. You'll have to do this:

1setX(x + 1);
2setY(y + 1);

And that's just not clean.

What you should do instead, is group related state together.

1const [state, setState] = useState({
2 x: 0,
3 y: 0,
4});

Now, you can update both x and y at the same time:

1setState({
2 x: state.x + 1,
3 y: state.y + 1,
4});

Super chill, right? Let's go deeper.

Warning

You can't update only one field in the state object without using the spread operator (copying the other fields). If you did setState({ x: state.x + 1 }), you'd lose the y value. So keep that in mind.

The useReducer hook

useReducer is a hook that is used to make state management more predictable.

It's a bit more complex than useState, but knowing how to use it is worth it, especially for state that has complex transformations.

Let's look at a fun example I think will make this hook's usefulness click.

To start: the bad

1import { useState } from "react";
2
3export default function ShoppingCart() {
4 const [items, setItems] = useState<{ id: string; price: number; qty: number }[]>([]);
5 const [subtotal, setSubtotal] = useState(0);
6 const [discount, setDiscount] = useState(0);
7 const [taxRate, setTaxRate] = useState(0.08);
8 const [checkedOut, setCheckedOut] = useState(false);
9
10 const addItem = (item: { id: string; price: number; qty: number }) => {
11 const newItems = [...items, item];
12 setItems(newItems);
13 const newSubtotal = newItems.reduce((sum, { price, qty }) => sum + price * qty, 0);
14 setSubtotal(newSubtotal);
15 // What if we forget to update subtotal here?
16 // The state becomes inconsistent. Boom, our first problem.
17 // And we're only one function in...
18 };
19
20 const removeItem = (id: string) => {
21 const newItems = items.filter(item => item.id !== id);
22 setItems(newItems);
23 // Forgot to update subtotal this time!
24 // And we need to recalculate discount too...
25 };
26
27 const applyDiscount = (percentage: number) => {
28 setDiscount(percentage);
29 // Need to update subtotal with new discount
30 // But what if items change after this?
31 // State is getting out of sync...you seein the problems?
32 };
33
34 return (
35 <section>
36 {/* ... */}
37 </section>
38 );
39}

As we see, yikes. Multiple related pieces of state are floating around with no guarantee they stay in sync.

Note

This is a simplified example. In a real app, you'd have more state and more complex transformations. That's where it gets even more nasty to exclusively use useState.

The good

Let's refactor using useReducer so every transformation lives in one place.

1import { useReducer } from "react";
2
3type CartItem = { id: string; price: number; qty: number };
4
5interface State {
6 items: CartItem[];
7 subtotal: number;
8 discount: number;
9 taxRate: number;
10 checkedOut: boolean;
11}
12
13type Action =
14 | { type: "addItem"; payload: CartItem }
15 | { type: "removeItem"; payload: string }
16 | { type: "discount"; payload: number }
17 | { type: "checkout" }
18 | { type: "reset" };
19
20const initialState: State = {
21 items: [],
22 subtotal: 0,
23 discount: 0,
24 taxRate: 0.08,
25 checkedOut: false,
26};
27
28const calcSubtotal = (items: CartItem[]) =>
29 items.reduce((sum, { price, qty }) => sum + price * qty, 0);
30
31function reducer(state: State, action: Action): State {
32 switch (action.type) {
33 case "addItem": {
34 const items = [...state.items, action.payload];
35 return { ...state, items, subtotal: calcSubtotal(items) };
36 }
37 case "removeItem": {
38 const items = state.items.filter(i => i.id !== action.payload);
39 return { ...state, items, subtotal: calcSubtotal(items) };
40 }
41 case "discount":
42 return { ...state, discount: action.payload };
43 case "checkout":
44 return { ...state, checkedOut: true };
45 case "reset":
46 return initialState;
47 default:
48 return state;
49 }
50}

Now updating state is a single-line affair:

1dispatch({ type: "addItem", payload: { id: "abc", price: 20, qty: 2 } });
2dispatch({ type: "discount", payload: 5 });
3dispatch({ type: "checkout" });

And here's the component - tidy and predictable:

1export default function ShoppingCart() {
2 const [state, dispatch] = useReducer(reducer, initialState);
3 const total = state.subtotal - state.discount + state.subtotal * state.taxRate;
4
5 const addItem = (item: CartItem) => dispatch({ type: "addItem", payload: item });
6 const removeItem = (id: string) => dispatch({ type: "removeItem", payload: id });
7 const applyDiscount = (percentage: number) => dispatch({ type: "discount", payload: percentage });
8 const checkout = () => dispatch({ type: "checkout" });
9 const resetCart = () => dispatch({ type: "reset" });
10
11 return (
12 <section>
13 {/* ... */}
14 </section>
15 );
16}

Would you look at that. Beautiful!

I'm not saying you should use useReducer for every state update. Far from it. But, when you have state that needs to be transformed in a certain way, it's a godsend. And, if you're using Redux, you're already using useReducer under the hood.

Tips and tricks

I wanted to add some rapid fire, real bugs I've fixed in client code, shown as before/after diffs so you can keep the 🟢 and yeet the 🔴 far as fuck. Please, take this with you and use it as a reference!


1. Merge variables that never split up

1- const [width, setWidth] = useState(0);
2- const [height, setHeight] = useState(0);
3
4+ type Size = { w: number; h: number };
5+ const [size, setSize] = useState<Size>({ w: 0, h: 0 });

2. Design away "impossible" states

1- const [isFetching, setIsFetching] = useState(false);
2- const [data, setData] = useState<User[] | null>(null);
3- const [error, setError] = useState<string | null>(null);
4
5+ type FetchState =
6+ | { status: "idle" }
7+ | { status: "loading" }
8+ | { status: "success"; data: User[] }
9+ | { status: "error"; msg: string };
10+
11+ const [fetch, setFetch] = useState<FetchState>({ status: "idle" });

3. Keep a pointer, not the entire object

1- const [selectedMovie, setSelectedMovie] = useState<Movie | null>(null);
2
3+ const [movieId, setMovieId] = useState<string | null>(null);
4+ const selectedMovie = useMemo(
5+ () => movies.find(m => m.id === movieId) ?? null,
6+ [movies, movieId]
7+ );

4. Let derived data be derived

1- const [cartItems, setCartItems] = useState<CartItem[]>([]);
2- const [total, setTotal] = useState(0);
3-
4- function addItem(item: CartItem) {
5- const next = [...cartItems, item];
6- setCartItems(next);
7- setTotal(next.reduce((s, i) => s + i.price * i.qty, 0));
8- }
9
10+ const [cartItems, setCartItems] = useState<CartItem[]>([]);
11+
12+ const total = useMemo(
13+ () => cartItems.reduce((s, i) => s + i.price * i.qty, 0),
14+ [cartItems]
15+ );
16+
17+ const addItem = (item: CartItem) =>
18+ setCartItems(prev => [...prev, item]);

5. Avoid redundant state

1- const [query, setQuery] = useState("");
2- const [filtered, setFiltered] = useState<Product[]>([]);
3-
4- useEffect(() => {
5- setFiltered(products.filter(p => p.name.includes(query)));
6- }, [query, products]);
7
8+ const [query, setQuery] = useState("");
9+ const filtered = useMemo(
10+ () => products.filter(p => p.name.includes(query)),
11+ [products, query]
12+ );

6. Don't adopt props, use them

1function Modal({ isOpen }: { isOpen: boolean }) {
2- const [open] = useState(isOpen); // ❌ frozen zombie value
3- return open ? <div>👋</div> : null;
4+ return isOpen ? <div>👋</div> : null; // ✅ breathe, little prop
5}

7. Flatten your deeply nested state

1- const [state, setState] = useState({
2- org: {
3- team: {
4- member: {
5- profile: {
6- settings: { theme: "dark" }
7- }
8- }
9- }
10- }
11- });
12-
13- setState(prev => ({
14- ...prev,
15- org: {
16- ...prev.org,
17- team: {
18- ...prev.org.team,
19- member: {
20- ...prev.org.team.member,
21- profile: {
22- ...prev.org.team.member.profile,
23- settings: { theme: "light" }
24- }
25- }
26- }
27- }
28- }));
29
30+ type MemberSettings = { id: string; theme: "dark" | "light" };
31+
32+ const [settingsById, setSettingsById] = useState<
33+ Record<string, MemberSettings>
34+ >({});
35+
36+ setSettingsById(prev => ({
37+ ...prev,
38+ [memberId]: { ...prev[memberId], theme: "light" }
39+ }));
Tip

If a single update makes you mutter "this can't be right," flatten first, refactor later.


If there's one thing you take away from this random thought, it should be this:

State is not easy. It's hard to get right. But with a few best practices, you can make it easier on yourself to build better web applications, faster. No AI tool is going to save you from yourself.