useState Patterns and Common Pitfalls

The fundamentals of useState are straightforward โ€” declare state, call the setter, React re-renders. But as your MERN Blog components grow more complex, several subtle patterns and pitfalls become important. In this lesson you will master the functional updater form for safe sequential updates, lazy initialisation for expensive initial values, how to decide between multiple state variables and one grouped object, and how to recognise and fix the most common useState bugs: stale closures, direct mutation, and unnecessary re-renders caused by state that should not be state.

The Functional Updater Form

// When new state depends on old state โ€” always use the functional form
// This guarantees you always act on the latest state value,
// even if multiple updates are batched together

// โ”€โ”€ Counter โ€” safe increment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
setCount(prev => prev + 1); // โœ“ uses latest queued value

// โ”€โ”€ Toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
setIsOpen(prev => !prev);  // โœ“

// โ”€โ”€ Add to list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
setPosts(prev => [...prev, newPost]); // โœ“

// โ”€โ”€ Remove from list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
setPosts(prev => prev.filter(p => p._id !== deletedId)); // โœ“

// โ”€โ”€ Update item in list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
setPosts(prev => prev.map(p =>
  p._id === updatedPost._id ? { ...p, ...updatedPost } : p
)); // โœ“

// โ”€โ”€ Object with one field updated โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
setUser(prev => ({ ...prev, name: 'Jane' })); // โœ“
Note: The functional updater form (setState(prev => newValue)) is not just for sequential updates โ€” it is also the correct pattern when your state update logic runs inside a closure that may capture a stale value. If your update function is defined inside a useEffect or a timer callback, the functional form ensures it always reads the current state value rather than the stale snapshot captured when the closure was created.
Tip: Use the functional updater form as your default for any setter that depends on the previous value. It is never wrong to use it and it protects you from subtle batching and concurrency bugs. The non-functional form (setState(newValue)) is only appropriate when the new value is completely independent of the previous value โ€” for example, resetting a form to an empty string or setting a flag to a specific known value.
Warning: The stale closure problem is one of the most confusing bugs in React. It occurs when an event handler or effect captures a state value at the time it was created, and that value becomes stale after subsequent state updates. The functional updater form is the primary defence against this. The second defence is listing the state value in a useEffect dependency array, covered in depth in Chapter 17.

Lazy Initialisation

// โ”€โ”€ Problem: expensive computation runs on every render โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const [posts, setPosts] = useState(JSON.parse(localStorage.getItem('posts')) || []);
// JSON.parse is called every render, even though useState only uses it on mount

// โ”€โ”€ Solution: lazy initialiser โ€” pass a function, not a value โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const [posts, setPosts] = useState(() => {
  // This function runs ONLY on the first render
  const saved = localStorage.getItem('posts');
  return saved ? JSON.parse(saved) : [];
});

// Lazy initialisation use cases:
// โœ“ Reading from localStorage or sessionStorage
// โœ“ Heavy computation to derive the initial value
// โœ“ Creating a complex initial object
// โœ“ Parsing URL search params for initial filter state

const [filters, setFilters] = useState(() => {
  const params = new URLSearchParams(window.location.search);
  return {
    tag:   params.get('tag')   || '',
    sort:  params.get('sort')  || 'createdAt',
    page:  parseInt(params.get('page') || '1', 10),
  };
});

Multiple Variables vs One Object

// โ”€โ”€ Rule: group state that always changes together โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

// โœ“ Separate variables: independent values that rarely update together
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [selectedTag, setSelectedTag] = useState('');
// Each can be updated independently without touching the others

// โœ“ One object: values that are always set/reset together
const [asyncState, setAsyncState] = useState({ data: null, loading: false, error: null });
// When a fetch starts: loading: true, error: null
// When it succeeds: data: result, loading: false, error: null
// When it fails: error: msg, loading: false, data: null
// These three always change together โ€” makes sense as one object

const setLoading  = ()      => setAsyncState({ data: null,  loading: true,  error: null });
const setSuccess  = (data)  => setAsyncState({ data,        loading: false, error: null });
const setError    = (error) => setAsyncState({ data: null,  loading: false, error });

// โ”€โ”€ Anti-pattern: artificial grouping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const [state, setState] = useState({ isOpen: false, count: 0, name: '' });
// These are unrelated โ€” updating isOpen requires spreading count and name
// Separate variables are cleaner here

Identifying Unnecessary State

// Ask: "Can this be computed from existing state?"
// If yes โ†’ compute it, do not store it

function PostListPage() {
  const [posts,   setPosts]   = useState([]);
  const [query,   setQuery]   = useState('');
  const [filter,  setFilter]  = useState('all');

  // โŒ WRONG โ€” derived state:
  // const [filteredPosts, setFilteredPosts] = useState([]);
  // const [postCount,     setPostCount]     = useState(0);

  // โœ… CORRECT โ€” derived values computed at render time:
  const filteredPosts = posts
    .filter(p => filter === 'all' || p.published === (filter === 'published'))
    .filter(p => p.title.toLowerCase().includes(query.toLowerCase()));

  const postCount  = filteredPosts.length;              // always in sync
  const hasResults = filteredPosts.length > 0;          // always in sync
  const isEmpty    = filteredPosts.length === 0 && !loading; // always in sync

  return (
    <div>
      <p>{postCount} posts found</p>
      <PostList posts={filteredPosts} />
    </div>
  );
}

The Stale Closure Bug โ€” and the Fix

// โ”€โ”€ The bug: handler captures stale state value โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function AutoSaveEditor() {
  const [content, setContent] = useState('');

  useEffect(() => {
    const timer = setInterval(() => {
      // content is captured from the initial render โ€” always ''!
      console.log('Auto-saving:', content); // stale closure bug
      localStorage.setItem('draft', content);
    }, 5000);
    return () => clearInterval(timer);
  }, []); // empty deps โ€” timer never re-created, content stays stale

  return <textarea value={content} onChange={e => setContent(e.target.value)} />;
}

// โ”€โ”€ Fix 1: Add content to the dependency array โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
useEffect(() => {
  const timer = setInterval(() => {
    localStorage.setItem('draft', content); // always fresh
  }, 5000);
  return () => clearInterval(timer);
}, [content]); // re-creates timer when content changes

// โ”€โ”€ Fix 2: Use a ref to hold the latest value โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const contentRef = useRef(content);
useEffect(() => { contentRef.current = content; }, [content]);

useEffect(() => {
  const timer = setInterval(() => {
    localStorage.setItem('draft', contentRef.current); // always latest via ref
  }, 5000);
  return () => clearInterval(timer);
}, []); // timer never re-created, but reads latest via ref

Common Mistakes

Mistake 1 โ€” Using useState for values that do not need to trigger re-renders

โŒ Wrong โ€” storing a timeout ID or a subscription reference in state:

const [timerId, setTimerId] = useState(null);
setTimerId(setTimeout(fn, 1000)); // triggers re-render unnecessarily

โœ… Correct โ€” use useRef for mutable values that should not cause re-renders:

const timerRef = useRef(null);
timerRef.current = setTimeout(fn, 1000); // no re-render โœ“

Mistake 2 โ€” Initialising state with a prop and expecting it to stay in sync

โŒ Wrong โ€” state initialised from a prop does not update when the prop changes:

function EditModal({ post }) {
  const [title, setTitle] = useState(post.title); // one-time init
  // If post.title changes in the parent โ€” title state does NOT update!
}

โœ… Correct โ€” use the prop directly if it is read-only, or use useEffect to sync:

useEffect(() => { setTitle(post.title); }, [post.title]); // syncs when prop changes โœ“

Mistake 3 โ€” Not resetting form state when the component is reused

โŒ Wrong โ€” form retains previous values when the parent passes a new post to edit:

function PostEditForm({ post }) {
  const [title, setTitle] = useState(post.title);
  // When parent passes a different post โ€” title still shows the old post's title
}

โœ… Correct โ€” add a key to force remount when the identity changes:

<PostEditForm key={post._id} post={post} />
// Changing post._id forces React to unmount + remount โ€” fresh state โœ“

Quick Reference

Pattern Code
Functional updater setState(prev => newValue)
Toggle setFlag(prev => !prev)
Lazy initialisation useState(() => expensiveComputation())
Group related state useState({ loading: false, data: null, error: null })
Update grouped field setState(prev => ({ ...prev, field: value }))
Force remount <Component key={uniqueId} />
Non-rendering mutable ref const ref = useRef(initialValue)

🧠 Test Yourself

Your PostEditForm initialises const [title, setTitle] = useState(post.title). When the user navigates from editing Post A to editing Post B (the parent passes a new post prop), the form still shows Post A’s title. Why and how do you fix it?