React developers frequently face a choice between useState and useRef when managing component state. On the surface, both seem to store values in components, but they operate on fundamentally different principles. The confusion arises because they solve different problems, and using the wrong one creates subtle bugs that only reveal themselves under specific conditions.
Here's what we'll explore: the core differences that matter, when each hook shines, common pitfalls developers encounter, and how to think about this choice in your day-to-day coding.
Table of Contents
- The Core Differences
- Understanding useState Internally
- Understanding useRef Internally
- When to Use useState
- When to Use useRef
- Side-by-Side Comparison
- Practical Example: Building a Form Manager
- Common Mistakes and Solutions
- FAQ
The Core Differences
Let me start with the most important distinction: useState is for data that affects rendering, while useRef is for data that doesn't.
When you update a useState state, React re-renders the component and all its children. When you update a useRef, nothing happens—the component doesn't re-render. This single fact cascades into dozens of implications about memory, performance, and behavior.
Another key difference: useState gives you a new reference every render (for the state value), while useRef gives you the exact same reference across every render. The current property of a ref object persists unchanged across renders.
Think of useState as "I want React to notice this changed and update the UI accordingly." Think of useRef as "I want to remember this value, but I don't care if the UI knows about it."
Understanding useState Internally
To truly understand when to use useState, you need to know how React manages it internally.
When you call useState(initialValue), React does several things:
First, it creates a state slot in the component's fiber node (React's internal representation). This slot stores your state value. React maintains a queue of state updates for your component. When you call the setter function, React doesn't immediately change the state—instead, it adds an update to this queue.
Here's the crucial part: React batches these updates. When you call setState multiple times in quick succession (say, within the same event handler), React collects all updates and processes them together in a single render cycle. This is why you sometimes see stale state values if you try to read state immediately after updating it.
import { useState } from 'react';
export function Counter(): JSX.Element {
const [count, setCount] = useState(0);
const handleMultipleUpdates = () => {
// Both of these updates batch together
setCount(count + 1);
setCount(count + 1);
// count is still 0 here—updates haven't been applied yet
console.log(count); // 0, not 2
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleMultipleUpdates}>Increment Twice</button>
</div>
);
}
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
const handleMultipleUpdates = () => {
setCount(count + 1);
setCount(count + 1);
console.log(count); // 0, not 2
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleMultipleUpdates}>Increment Twice</button>
</div>
);
}
The setter function accepts either a value or an updater function. The updater function pattern is essential when your new state depends on the previous state:
const handleCorrectIncrement = () => {
// Updater function receives the actual previous state value
setCount(prev => prev + 1);
setCount(prev => prev + 1); // This now correctly increments from 1, not from the original 0
// count is still 0 here, but both updates queued correctly
console.log(count); // 0
};
const handleCorrectIncrement = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
console.log(count); // 0
};
This difference matters enormously. If you write setCount(count + 1) twice, both updates use the same stale count value from closure. If you write setCount(prev => prev + 1) twice, the second update receives the result of the first update.
Understanding useRef Internally
useRef is much simpler. When you call useRef(initialValue), React creates a plain JavaScript object with a current property and returns the same object on every render.
That's it. There's no magic. No batching. No re-rendering. It's just a stable object reference that React helps you maintain across renders.
import { useRef, useEffect } from 'react';
interface InputHandle {
focus: () => void;
}
export function TextInput(): JSX.Element {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Every time this component renders, inputRef.current points to the same DOM element
console.log('Render occurred, but inputRef.current stays the same');
});
const handleFocus = () => {
// This works because inputRef.current is stable
if (inputRef.current) {
inputRef.current.focus();
}
};
return (
<>
<input ref={inputRef} type="text" />
<button onClick={handleFocus}>Focus Input</button>
</>
);
}
import { useRef, useEffect } from 'react';
export function TextInput() {
const inputRef = useRef(null);
useEffect(() => {
console.log('Render occurred, inputRef.current stays the same');
});
const handleFocus = () => {
if (inputRef.current) {
inputRef.current.focus();
}
};
return (
<>
<input ref={inputRef} type="text" />
<button onClick={handleFocus}>Focus Input</button>
</>
);
}
The critical behavior: when you update inputRef.current = newValue, nothing triggers. React doesn't schedule a re-render. The component continues until something else causes a re-render (perhaps a useState update), at which point your component reads the new value from ref.current.
This is actually a feature. It's precisely the behavior you want when you're dealing with imperative APIs (like DOM APIs, library instances, timers) that exist outside React's declarative world.
When to Use useState
Use useState when your data affects what the user sees—when it's part of your render output, either directly or indirectly.
Examples include:
- Form input values that display in the UI
- UI state (modals open/closed, tabs selected, accordion expanded/collapsed)
- Computed data that changes how the component looks
- Counters, lists, search filters—anything the user interacts with and expects to see updated immediately
Here's why: if you use useRef for form input, the user types in the box, but the input never re-renders to show their typing. They've typed characters into the DOM, but React has no idea it happened. This breaks the React mental model and creates all sorts of synchronization problems.
// ❌ WRONG: Form input with useRef
import { useRef } from 'react';
export function BadForm(): JSX.Element {
const inputRef = useRef('');
const handleSubmit = () => {
// This works, but the UI never shows what the user typed
console.log('User typed:', inputRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} type="text" />
<button type="submit">Submit</button>
</form>
);
}
// ✅ CORRECT: Form input with useState
import { useState } from 'react';
export function GoodForm(): JSX.Element {
const [input, setInput] = useState('');
const handleSubmit = () => {
console.log('User typed:', input);
};
return (
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
type="text"
/>
<button type="submit">Submit</button>
</form>
);
}
// ❌ WRONG
import { useRef } from 'react';
export function BadForm() {
const inputRef = useRef('');
const handleSubmit = () => {
console.log('User typed:', inputRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} type="text" />
<button type="submit">Submit</button>
</form>
);
}
// ✅ CORRECT
import { useState } from 'react';
export function GoodForm() {
const [input, setInput] = useState('');
const handleSubmit = () => {
console.log('User typed:', input);
};
return (
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
type="text"
/>
<button type="submit">Submit</button>
</form>
);
}
With useState, React controls the input's value and ensures it always reflects what the user typed. The re-render keeps the UI synchronized with your state.
When to Use useRef
Use useRef when you need to interact with the DOM directly, manage library instances that live outside React, or track values that don't affect rendering.
DOM manipulation examples:
- Focusing elements (
inputRef.current.focus()) - Triggering play/pause on video players
- Getting scroll position for virtualization libraries
- Measuring element dimensions
Library instance examples:
- Keeping a reference to a chart library instance
- Managing WebSocket connections
- Storing intervals or timeouts (though React 19's cleaner approach is better)
Tracking values without re-rendering:
- Counting how many times a component has rendered (useful for debugging)
- Storing the previous value of a prop (to detect changes)
- Tracking animation state that doesn't affect UI
Here's a practical DOM manipulation example:
import { useRef, useState } from 'react';
interface VideoPlayerHandle {
play: () => void;
pause: () => void;
}
export function VideoPlayer(): JSX.Element {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const handlePlayPause = () => {
if (!videoRef.current) return;
// Direct DOM manipulation—this is what useRef is for
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
};
return (
<div>
<video
ref={videoRef}
width="320"
height="240"
src="video.mp4"
/>
<button onClick={handlePlayPause}>
{isPlaying ? 'Pause' : 'Play'}
</button>
</div>
);
}
import { useRef, useState } from 'react';
export function VideoPlayer() {
const videoRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
const handlePlayPause = () => {
if (!videoRef.current) return;
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
};
return (
<div>
<video
ref={videoRef}
width="320"
height="240"
src="video.mp4"
/>
<button onClick={handlePlayPause}>
{isPlaying ? 'Pause' : 'Play'}
</button>
</div>
);
}
Notice that we still use useState for isPlaying because the button text changes when you click it. The ref handles the imperative DOM API (calling play() and pause()), while state handles the declarative UI (what the button shows).
Here's a more advanced example—tracking previous values:
import { useState, useRef, useEffect } from 'react';
interface PreviousValueTrackerProps {
value: number;
}
export function PreviousValueTracker({ value }: PreviousValueTrackerProps): JSX.Element {
const prevValueRef = useRef<number>(0);
useEffect(() => {
// After render, update the ref to the current value
prevValueRef.current = value;
}, [value]);
return (
<div>
<p>Current: {value}</p>
<p>Previous: {prevValueRef.current}</p>
<p>Changed: {value !== prevValueRef.current ? 'Yes' : 'No'}</p>
</div>
);
}
import { useState, useRef, useEffect } from 'react';
export function PreviousValueTracker({ value }) {
const prevValueRef = useRef(0);
useEffect(() => {
prevValueRef.current = value;
}, [value]);
return (
<div>
<p>Current: {value}</p>
<p>Previous: {prevValueRef.current}</p>
<p>Changed: {value !== prevValueRef.current ? 'Yes' : 'No'}</p>
</div>
);
}
This pattern is useful in complex components where you need to react to prop changes. The ref stores the previous value without triggering a re-render, and you compare it in the effect or render.
Side-by-Side Comparison
| Aspect | useState | useRef |
|---|---|---|
| Triggers re-render | ✅ Yes | ❌ No |
| New reference each render | ✅ Yes (updater fn, not value) | ❌ No (same object always) |
| Ideal for | Rendering data | Non-rendering data |
| Used with | Controlled inputs, display state | DOM refs, library instances |
| Performance impact | Can cause unnecessary re-renders | Zero re-render impact |
| Mutable by design | No—use setter functions | Yes—direct mutation OK |
| Initial value | Runs on first render only | Runs on first render only |
| Updates are batched | ✅ Yes | ❌ No |
| Read value in JSX | ✅ Yes (it's current) | ❌ Yes, but ref.current |
Practical Example: Building a Form Manager
Let's build a realistic form component that combines both hooks. This is a search form that needs to validate, display errors, and focus on the input when appropriate.
import { useState, useRef, useCallback } from 'react';
interface FormErrors {
query?: string;
general?: string;
}
interface SearchFormProps {
onSearch: (query: string) => Promise<void>;
}
export function SearchForm({ onSearch }: SearchFormProps): JSX.Element {
// State for data that affects rendering
const [query, setQuery] = useState('');
const [errors, setErrors] = useState<FormErrors>({});
const [isLoading, setIsLoading] = useState(false);
const [searchHistory, setSearchHistory] = useState<string[]>([]);
// Ref for DOM manipulation—we need to focus on input when error occurs
const inputRef = useRef<HTMLInputElement>(null);
// Ref for tracking if this is the initial render (non-rendering state)
const isInitialRenderRef = useRef(true);
// Ref for debounce timer (imperative timeout management)
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const validateAndSearch = useCallback(async () => {
const trimmedQuery = query.trim();
// Validation
if (!trimmedQuery) {
setErrors({ query: 'Search query cannot be empty' });
// Focus input immediately when validation fails
inputRef.current?.focus();
return;
}
if (trimmedQuery.length < 2) {
setErrors({ query: 'Minimum 2 characters required' });
inputRef.current?.focus();
return;
}
setErrors({});
setIsLoading(true);
try {
await onSearch(trimmedQuery);
// Add to history only on successful search
setSearchHistory(prev =>
[trimmedQuery, ...prev].slice(0, 5) // Keep last 5
);
} catch (err) {
setErrors({
general: err instanceof Error ? err.message : 'Search failed'
});
} finally {
setIsLoading(false);
}
}, [query, onSearch]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.currentTarget.value);
setErrors({}); // Clear errors as user types
// Debounce: clear previous timer and set new one
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// This timeout is imperative—not state—so useRef is correct
debounceTimerRef.current = setTimeout(() => {
validateAndSearch();
}, 300);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Clear debounce timer if user submits manually
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
validateAndSearch();
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
ref={inputRef}
type="text"
value={query}
onChange={handleInputChange}
placeholder="Search..."
disabled={isLoading}
aria-invalid={Boolean(errors.query)}
/>
{errors.query && <span role="alert">{errors.query}</span>}
</div>
{errors.general && <div role="alert">{errors.general}</div>}
<button type="submit" disabled={isLoading}>
{isLoading ? 'Searching...' : 'Search'}
</button>
{searchHistory.length > 0 && (
<div>
<p>Recent searches:</p>
<ul>
{searchHistory.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
</div>
)}
</form>
);
}
import { useState, useRef, useCallback } from 'react';
export function SearchForm({ onSearch }) {
// State for rendering
const [query, setQuery] = useState('');
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
const [searchHistory, setSearchHistory] = useState([]);
// Ref for DOM
const inputRef = useRef(null);
// Ref for non-rendering tracking
const isInitialRenderRef = useRef(true);
// Ref for imperative timer
const debounceTimerRef = useRef(null);
const validateAndSearch = useCallback(async () => {
const trimmedQuery = query.trim();
if (!trimmedQuery) {
setErrors({ query: 'Search query cannot be empty' });
inputRef.current?.focus();
return;
}
if (trimmedQuery.length < 2) {
setErrors({ query: 'Minimum 2 characters required' });
inputRef.current?.focus();
return;
}
setErrors({});
setIsLoading(true);
try {
await onSearch(trimmedQuery);
setSearchHistory(prev =>
[trimmedQuery, ...prev].slice(0, 5)
);
} catch (err) {
setErrors({
general: err.message || 'Search failed'
});
} finally {
setIsLoading(false);
}
}, [query, onSearch]);
const handleInputChange = (e) => {
setQuery(e.currentTarget.value);
setErrors({});
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
validateAndSearch();
}, 300);
};
const handleSubmit = (e) => {
e.preventDefault();
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
validateAndSearch();
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
ref={inputRef}
type="text"
value={query}
onChange={handleInputChange}
placeholder="Search..."
disabled={isLoading}
aria-invalid={Boolean(errors.query)}
/>
{errors.query && <span role="alert">{errors.query}</span>}
</div>
{errors.general && <div role="alert">{errors.general}</div>}
<button type="submit" disabled={isLoading}>
{isLoading ? 'Searching...' : 'Search'}
</button>
{searchHistory.length > 0 && (
<div>
<p>Recent searches:</p>
<ul>
{searchHistory.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
</div>
)}
</form>
);
}
This example shows all three uses together:
- useState for
query,errors,isLoading, andsearchHistory—all data that affects what renders - useRef for
inputRef—direct DOM access to focus the input when validation fails - useRef for
debounceTimerRef—managing an imperative API (setTimeout/clearTimeout) that doesn't need React to know about
Common Mistakes and Solutions
Mistake 1: Using useRef for Form State
// ❌ WRONG
export function Form(): JSX.Element {
const nameRef = useRef('');
return (
<>
<input
type="text"
value={nameRef.current} // Never updates visually
onChange={(e) => { nameRef.current = e.target.value; }}
/>
</>
);
}
Why it fails: Updating nameRef.current doesn't trigger a re-render, so the input value never visually updates even though the data changed.
Solution: Use useState for all form data.
Mistake 2: Using useState for DOM References
// ❌ WRONG
export function Player(): JSX.Element {
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null);
const play = () => videoElement?.play();
return <video ref={setVideoElement} />;
}
Why it's problematic: Every time you set the video ref, React re-renders. You're triggering unnecessary renders just to store a DOM reference.
Solution: Use useRef for DOM references.
Mistake 3: Mutating useState Values Directly
// ❌ WRONG
export function List(): JSX.Element {
const [items, setItems] = useState(['a', 'b', 'c']);
const addItem = () => {
items.push('d'); // Direct mutation!
setItems(items); // React doesn't detect this change
};
return <div>{items.join(', ')}</div>;
}
Why it fails: React compares the new value to the old value by reference. If you mutate the array and pass the same reference, React thinks nothing changed.
Solution: Always create new values for useState:
// ✅ CORRECT
const addItem = () => {
setItems([...items, 'd']); // New array, same contents + addition
// Or: setItems(prev => [...prev, 'd']);
};
Mistake 4: Mutating useRef Values in Event Handlers Without Re-render
// ⚠️ SUBTLE BUG
export function Counter(): JSX.Element {
const countRef = useRef(0);
const handleClick = () => {
countRef.current++;
console.log('Count:', countRef.current); // Logs correctly
};
return (
<div>
<p>{countRef.current}</p> {/* Always shows 0! */}
<button onClick={handleClick}>Increment</button>
</div>
);
}
Why it's wrong: The <p> always shows 0 because React never re-renders. The ref updates, but React never knows to display it.
Solution: If you need the value to display, use useState:
// ✅ CORRECT
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // Triggers re-render
};
FAQ
Q: Can I use useRef to store data that I don't want to lose between renders?
A: Yes, that's exactly one of useRef's purposes. A ref's value persists across every render, and it's never reset unless you explicitly change it. However, if the component unmounts, the ref disappears (all state is tied to the component instance). If you need data to survive across component unmounts, you need external storage (localStorage, a database, a global state manager like Redux or Zustand).
Q: Should I use useRef for objects that I'm passing to child components?
A: No. If a child component depends on this object, use useState instead. When you pass a ref through props, the child always receives the same object reference, which prevents the child from re-rendering when the data changes. This breaks the parent-child data flow. Use refs only for imperative APIs and DOM elements that children don't need to react to.
Q: What's the difference between useRef and just creating a regular variable in my component?
A: Regular variables are recreated every render. If you create let id = 0 at the top of your component, it starts as 0 every single render—it doesn't persist. A ref persists across renders. The ref object itself is the same object every time your component renders, so it's a stable place to store data.
// Regular variable—reset every render
export function Component(): JSX.Element {
let id = 0; // Reset to 0 every time component renders
const increment = () => {
id++; // This actually works, but...
console.log(id); // Only logs within this render, then resets
};
return <button onClick={increment}>Increment</button>;
}
// useRef—persists across renders
export function Component(): JSX.Element {
const idRef = useRef(0); // Same object every render
const increment = () => {
idRef.current++;
console.log(idRef.current); // Increments across renders
};
return <button onClick={increment}>Increment</button>;
}
Q: Is it bad to have many useRefs in a component?
A: Not inherently. If you're using them for the right purposes (DOM refs, library instances, timers), it's fine to have several. The only concern is readability—if you have many refs, your component might be doing too much. Consider breaking it into smaller components. refs don't have performance implications like unnecessary state updates do.
Q: Can I update a ref and also trigger a re-render?
A: You can update a ref, but it won't trigger a re-render. If you need both behaviors, you typically combine them:
const handleUpdate = () => {
countRef.current++;
setCounter(countRef.current); // Explicit re-render trigger
};
Or more commonly, just use useState if you need the value to display.
Q: What happens if I use useRef with a complex object and mutate it?
A: The mutation persists because refs are mutable by design. However, other components won't know about the mutation unless you trigger a re-render elsewhere. This can lead to subtle bugs where data changes but the UI doesn't. Use refs for data that's truly independent of rendering, or explicitly trigger updates via state when needed.
Related Articles:
- React Hooks Fundamentals: Beyond useState and useEffect
- State Management Patterns in React
- Understanding React's Fiber Architecture
Questions? Have you encountered situations where you used the wrong hook? Share your experiences in the comments—let's discuss real patterns from production code.
Google AdSense Placeholder
CONTENT SLOT