2024-12-27 Web Development

Handling Stale State in React with useRef for Dynamic Components

By O. Wolfson

When building React applications, you may encounter situations where state updates don’t seem to take effect in certain callbacks or dynamic components. This often happens because of how React closures capture state at the time of a component’s render. Let’s dive into an example problem, why it occurs, and how to resolve it effectively using useRef in a client-side React or Next.js component.


The Problem: Stale State in a Callback

Imagine you are building a donation form with the following requirements:

  1. A user can input a donation amount.
  2. A "Submit Donation" button triggers an asynchronous operation to process the donation with the entered amount.
  3. The donation amount displayed should always match the most recent user input.

Here’s what your initial implementation might look like:

jsx
"use client";

import { useState } from "react";

export default function DonationForm() {
  const [donationAmount, setDonationAmount] = useState("1");

  const handleSubmit = async () => {
    console.log(`Processing donation of: $${donationAmount}`);
    // Simulate an API call
    await new Promise((resolve) => setTimeout(resolve, 2000));
    console.log(`Donation of $${donationAmount} completed!`);
  };

  return (
    <div>
      <h1>Donation Form</h1>
      <input
        type="number"
        value={donationAmount}
        onChange={(e) => setDonationAmount(e.target.value)}
      />
      <button onClick={handleSubmit}>Submit Donation</button>
    </div>
  );
}

What Goes Wrong?

If the user updates the donation amount multiple times before clicking "Submit Donation," the console might show outdated values for donationAmount. For example:

  1. The user sets the amount to $10.
  2. Then updates it to $20 before clicking submit.
  3. The handleSubmit logs Processing donation of: $10 instead of $20.

This happens because the handleSubmit function is tied to the state value captured at the time of the render. Subsequent state updates don’t update the captured closure.


The Solution: Using useRef to Access the Latest State

To fix this issue, we can use a React ref to store and retrieve the most up-to-date value of the state. A ref is a mutable object that persists across renders, allowing us to bypass React's closure behavior.

Updated Implementation

Here’s how we can update our component to ensure the latest state value is always used:

jsx
"use client";

import { useState, useRef, useEffect } from "react";

export default function DonationForm() {
  const [donationAmount, setDonationAmount] = useState("1");
  const donationAmountRef = useRef(donationAmount); // Create a ref to store the state

  // Keep the ref updated with the latest state value
  useEffect(() => {
    donationAmountRef.current = donationAmount;
  }, [donationAmount]);

  const handleSubmit = async () => {
    const latestAmount = donationAmountRef.current; // Always access the ref's current value
    console.log(`Processing donation of: $${latestAmount}`);
    // Simulate an API call
    await new Promise((resolve) => setTimeout(resolve, 2000));
    console.log(`Donation of $${latestAmount} completed!`);
  };

  return (
    <div>
      <h1>Donation Form</h1>
      <input
        type="number"
        value={donationAmount}
        onChange={(e) => setDonationAmount(e.target.value)}
      />
      <button onClick={handleSubmit}>Submit Donation</button>
    </div>
  );
}

Why This Works

  1. Ref for Persisting State:

    • The useRef hook creates an object with a .current property that persists across renders.
    • Unlike state, updating a ref does not trigger a re-render, making it ideal for accessing mutable values without performance concerns.
  2. Syncing State and Ref:

    • We use useEffect to update the ref whenever the donationAmount state changes, ensuring donationAmountRef.current always holds the latest value.
  3. Accessing Latest Value:

    • Instead of relying on a potentially stale closure, we access the latest value directly from donationAmountRef.current in the handleSubmit function.

Key Takeaways

  1. When to Use Refs:

    • Use refs when you need to access the latest value of a variable in a callback without triggering a re-render.
    • They’re particularly useful for integrations with third-party libraries, debounced input handling, or managing state in asynchronous functions.
  2. Avoid Overusing Refs:

    • While refs solve specific problems, they bypass React’s declarative state model. Overusing refs can lead to hard-to-maintain code. Use them judiciously and only when closures or state models fall short.
  3. Understanding State Closures:

    • React closures tie callbacks to the state values at the time of the component’s render. If a callback needs the most up-to-date value, a ref can bridge the gap.

By understanding how state and refs interact, you can write more reliable React components that avoid pitfalls like stale state in callbacks. This approach is especially valuable in client-side components in Next.js, where asynchronous interactions are common.