December 27, 2024
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.
Imagine you are building a donation form with the following requirements:
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>
);
}
If the user updates the donation amount multiple times before clicking "Submit Donation," the console might show outdated values for donationAmount. For example:
$10.$20 before clicking submit.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.
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.
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}
= => setDonationAmount(e.target.value)}
/>
Submit Donation
);
}
Ref for Persisting State:
useRef hook creates an object with a .current property that persists across renders.ref does not trigger a re-render, making it ideal for accessing mutable values without performance concerns.Syncing State and Ref:
useEffect to update the ref whenever the donationAmount state changes, ensuring donationAmountRef.current always holds the latest value.Accessing Latest Value:
donationAmountRef.current in the handleSubmit function.When to Use Refs:
Avoid Overusing Refs:
Understanding State Closures:
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.