20 minlesson

State Patterns and Best Practices

State Patterns and Best Practices

Now that you understand useState basics, let's explore common patterns, pitfalls, and best practices for managing state effectively.

Derived State (Computed Values)

Don't store values that can be calculated from existing state:

jsx
1// BAD - redundant state
2function Cart({ items }) {
3 const [total, setTotal] = useState(0);
4
5 useEffect(() => {
6 setTotal(items.reduce((sum, item) => sum + item.price, 0));
7 }, [items]);
8
9 return <p>Total: ${total}</p>;
10}
11
12// GOOD - derive from existing data
13function Cart({ items }) {
14 // Computed on every render - no extra state needed
15 const total = items.reduce((sum, item) => sum + item.price, 0);
16
17 return <p>Total: ${total}</p>;
18}

Rule: If you can calculate it from props or other state, don't add new state.

Common derived values:

  • Totals, averages, counts
  • Filtered/sorted lists
  • Formatted display values
  • Validation states (isValid = name.length > 0)

Normalizing State Structure

For complex data, consider normalizing (like a database):

jsx
1// Nested structure (harder to update)
2const [state, setState] = useState({
3 users: [
4 { id: 1, name: 'Alice', posts: [{ id: 1, title: 'Hello' }] }
5 ]
6});
7
8// Normalized structure (easier to update)
9const [state, setState] = useState({
10 users: {
11 1: { id: 1, name: 'Alice' }
12 },
13 posts: {
14 1: { id: 1, userId: 1, title: 'Hello' }
15 },
16 userIds: [1],
17 postIds: [1]
18});
19
20// Update is simpler
21const updatePost = (postId, title) => {
22 setState(prev => ({
23 ...prev,
24 posts: {
25 ...prev.posts,
26 [postId]: { ...prev.posts[postId], title }
27 }
28 }));
29};

Using Immer for Complex Updates

For deeply nested state, consider Immer:

jsx
1import { produce } from 'immer';
2
3function TodoApp() {
4 const [state, setState] = useState({
5 todos: [
6 { id: 1, text: 'Learn React', done: false }
7 ],
8 filter: 'all'
9 });
10
11 // Without Immer - verbose
12 const toggleTodo = (id) => {
13 setState(prev => ({
14 ...prev,
15 todos: prev.todos.map(todo =>
16 todo.id === id ? { ...todo, done: !todo.done } : todo
17 )
18 }));
19 };
20
21 // With Immer - feels like mutation but is immutable
22 const toggleTodoImmer = (id) => {
23 setState(produce(draft => {
24 const todo = draft.todos.find(t => t.id === id);
25 if (todo) todo.done = !todo.done;
26 }));
27 };
28}

Immer lets you write "mutating" code that produces immutable updates.

State Colocation

Keep state as close as possible to where it's used:

jsx
1// BAD - state at top level when only Form needs it
2function App() {
3 const [formData, setFormData] = useState({}); // Lifted too high
4
5 return (
6 <div>
7 <Header />
8 <Sidebar />
9 <Form data={formData} onChange={setFormData} /> // Only user
10 <Footer />
11 </div>
12 );
13}
14
15// GOOD - state in the component that uses it
16function App() {
17 return (
18 <div>
19 <Header />
20 <Sidebar />
21 <Form /> {/* Manages its own state */}
22 <Footer />
23 </div>
24 );
25}
26
27function Form() {
28 const [formData, setFormData] = useState({}); // Colocated
29 // ...
30}

When to lift state:

  • Multiple components need the same state
  • A parent needs to coordinate children
  • Sibling components need to share data

Avoiding State Duplication

Don't duplicate data that exists elsewhere:

jsx
1// BAD - duplicating items prop into state
2function ItemList({ items }) {
3 const [localItems, setLocalItems] = useState(items); // Duplicate!
4
5 // Now items prop and localItems can get out of sync
6}
7
8// GOOD - use prop directly, lift changes to parent
9function ItemList({ items, onItemsChange }) {
10 // Use items directly, notify parent of changes
11 const removeItem = (id) => {
12 onItemsChange(items.filter(item => item.id !== id));
13 };
14}
15
16// GOOD - if you need local modifications, use key to reset
17function ItemEditor({ item, key = item.id }) {
18 const [draft, setDraft] = useState(item);
19
20 // When key changes (different item), state resets automatically
21}

Resetting State

Three ways to reset component state:

1. Set state back to initial value

jsx
1function Form() {
2 const [name, setName] = useState('');
3
4 const reset = () => setName('');
5
6 return <input value={name} onChange={e => setName(e.target.value)} />;
7}

2. Change the key prop (remount component)

jsx
1function App() {
2 const [formKey, setFormKey] = useState(0);
3
4 const resetForm = () => setFormKey(k => k + 1);
5
6 return <Form key={formKey} />; // New key = new component instance
7}

3. Use useReducer with reset action

jsx
1function reducer(state, action) {
2 if (action.type === 'reset') return initialState;
3 // ...other actions
4}

Common State Pitfalls

1. Stale Closures

jsx
1function Counter() {
2 const [count, setCount] = useState(0);
3
4 // Bug: always logs initial count (0) due to closure
5 const handleClick = () => {
6 setTimeout(() => {
7 console.log(count); // Always 0!
8 }, 3000);
9 };
10
11 // Fix: use a ref or functional update
12 const handleClickFixed = () => {
13 setTimeout(() => {
14 setCount(prev => {
15 console.log(prev); // Current value
16 return prev;
17 });
18 }, 3000);
19 };
20}

2. Object/Array Reference Equality

jsx
1// Bug: infinite loop because {} !== {} on every render
2useEffect(() => {
3 fetchData(options);
4}, [{ sort: 'name' }]); // New object every render!
5
6// Fix: use primitive or memoize
7const [sortBy, setSortBy] = useState('name');
8useEffect(() => {
9 fetchData({ sort: sortBy });
10}, [sortBy]);

3. State Updates in Loops

jsx
1// Bug: only increments by 1
2const incrementMany = () => {
3 for (let i = 0; i < 5; i++) {
4 setCount(count + 1); // All use same stale 'count'
5 }
6};
7
8// Fix: use functional updates
9const incrementMany = () => {
10 for (let i = 0; i < 5; i++) {
11 setCount(prev => prev + 1); // Each uses latest value
12 }
13};

4. Initializing State from Props (Anti-pattern)

jsx
1// Anti-pattern: state initialized from props won't update
2function UserCard({ user }) {
3 const [name, setName] = useState(user.name);
4 // If user prop changes, name state doesn't update!
5}
6
7// Better options:
8// 1. Use prop directly (no state)
9function UserCard({ user }) {
10 return <h1>{user.name}</h1>;
11}
12
13// 2. Use key to reset when prop changes
14<UserCard key={user.id} user={user} />
15
16// 3. Controlled component
17function UserCard({ name, onNameChange }) {
18 return <input value={name} onChange={e => onNameChange(e.target.value)} />;
19}

State Design Checklist

Before adding state, ask:

  1. Can it be derived? Calculate from props/other state instead
  2. Does it change? If not, use a constant or prop
  3. Who owns it? Keep state where it's used
  4. Is it duplicated? Use a single source of truth
  5. Is the structure right? Flat > nested, normalized > duplicated

useState vs useReducer

useReducer is better for:

  • Complex state logic
  • Multiple related values
  • State transitions with clear actions
jsx
1// useState - good for simple state
2const [count, setCount] = useState(0);
3
4// useReducer - good for complex state
5const [state, dispatch] = useReducer(reducer, {
6 items: [],
7 loading: false,
8 error: null
9});
10
11// Actions are explicit
12dispatch({ type: 'ADD_ITEM', item });
13dispatch({ type: 'SET_LOADING', loading: true });
14dispatch({ type: 'SET_ERROR', error });

We'll cover useReducer in depth in the Refs & Context topic.

Summary

State best practices:

  1. Derive when possible - Don't store computed values
  2. Colocate state - Keep it close to where it's used
  3. Avoid duplication - Single source of truth
  4. Use functional updates - When new state depends on old
  5. Watch for closures - Stale values in callbacks
  6. Reset with key - Change key to remount
  7. Consider useReducer - For complex state logic

Next, let's put these patterns into practice with a shopping cart workshop!