Redundant State and When to Use Refs
Storing the same data in two pieces of state, or using useState for values that should never trigger a re-render -- these are subtle bugs waiting to happen. Here is how to spot them and fix them.
Two more anti-patterns from the state anti-patterns chapter -- different symptoms, same root cause: state that should not exist.
The first creates unnecessary re-renders. The second creates stale data. Both are fixable in under a minute once you can see them. The previous post covered deriving state inside useEffect -- this one covers the two patterns that are easy to miss because they look like correct code.
Refs for Values That Do Not Affect Rendering
Here is a debounce implementation that most React developers write on their first attempt.
function SearchBar() {
const [query, setQuery] = useState('');
const [debounceId, setDebounceId] = useState<NodeJS.Timeout | null>(null);
function handleChange(value: string) {
setQuery(value);
if (debounceId) clearTimeout(debounceId);
const id = setTimeout(() => runSearch(value), 300);
setDebounceId(id);
}
// ...
}Every time the user types, setDebounceId triggers a re-render. The component function runs again, the DOM is diffed, nothing visible changes -- all because a timeout ID changed.
The timeout ID has no effect on what is rendered.
This is the distinction that matters:
- State is for values that affect what the user sees. When state changes, React should re-render.
- Refs are for internal bookkeeping. When they change, nothing visual changes and no re-render is needed.
function SearchBar() {
const [query, setQuery] = useState('');
const debounceRef = useRef<NodeJS.Timeout | null>(null);
function handleChange(value: string) {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => runSearch(value), 300);
}
// ...
}debounceRef.current can be read and written freely without triggering a re-render. The component only updates when query changes -- which it should, because query is what the user sees.
Refs are not just for DOM elements. They are for any value that needs to persist across renders without causing them.
Other values that belong in refs:
- Animation frame IDs from
requestAnimationFrame - WebSocket connections
- Subscription handles
- Previous values you want to compare against current ones
If the value never appears in JSX, ask whether it belongs in a ref.
Redundant State
The second pattern looks unrelated but has the same structure: two sources of truth for one fact.
type Course = { id: string; title: string; duration: number; price: number };
function CourseLibrary({ courses }: { courses: Course[] }) {
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
function handleSelect(course: Course) {
setSelectedCourse(course);
}
// ...
}selectedCourse is a copy of an item that already exists in courses. Same shape, same data, stored in two places.
The danger: stale data.
What happens when courses updates? The app fetches new pricing, a course becomes unavailable, metadata changes. The courses prop reflects the new reality. selectedCourse does not.
It is a frozen snapshot of what the course looked like at selection time.
This is not theoretical. It shows up whenever:
- The parent fetches and refreshes a list from a server
- Price, availability, or metadata can change while an item is selected
- Multiple components can mutate the same underlying data
The fix: store only what you actually own, and derive everything else.
function CourseLibrary({ courses }: { courses: Course[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const selectedCourse = courses.find(c => c.id === selectedId) ?? null;
function handleSelect(id: string) {
setSelectedId(id);
}
// ...
}selectedId is a string -- the minimum information you own. selectedCourse is a calculation. When courses updates, selectedCourse automatically reflects the new data because it is derived fresh on every render.
Store the minimum. Derive the rest.
The same problem appears when you mirror props directly into state.
function UserProfile({ user }: { user: User }) {
const [name, setName] = useState(user.name);
return <p>{name}</p>;
}The name state is initialized from user.name once, at mount. When user updates -- because the parent re-fetched, or the user edited their profile elsewhere -- name stays frozen at its original value. You have traded a live reference for a snapshot.
If you never call setName, this is not state. It is a stale copy of a prop.
function UserProfile({ user }: { user: User }) {
return <p>{user.name}</p>;
}Use the prop directly. If the component needs to allow local edits (like a form), only then is lifting that value into state justified -- and even then, you need a strategy to sync when the prop changes externally.
A related smell: a useState with no setter visible anywhere in the component. If you can never change a value from within the component, it is not state. Either it belongs outside the component as a module-level constant, or it should arrive as a prop so the component stays self-contained and testable.
ExpandRedundant state diagram: anti-pattern shows selectedCourse as a stored copy that becomes stale when courses updates; fix shows selectedId as the only stored value with selectedCourse derived fresh on every render
Both patterns here share the same lesson. State is a responsibility. Every new useState you add is a thing you are now in charge of -- making sure it is correct, keeping it fresh, protecting it from going stale. The discipline is not "avoid state" but "question every piece of state until you are sure it belongs."
The next pattern is harder to see but more damaging when it accumulates: Avoiding Cascading Effects looks at what happens when useEffect hooks start triggering each other across a component.
The Essentials
- Refs are for values that do not affect rendering. Timeout IDs, animation handles, connections -- these belong in
useRef, notuseState. Every unnecessary state update is a wasted re-render. - Storing a full object when you only need the ID is redundant state. When the source list updates, your stored copy does not. Store the minimum (an ID), derive the full object.
- Single source of truth means one place per fact. Two pieces of state that represent the same thing will eventually disagree. The logic that keeps them in sync is the first thing to break under pressure.
Further Reading and Watching
- Referencing Values with Refs -- React Docs: When refs are the right tool, how they differ from state, and the common mistake of reading or writing refs during rendering.
- Choosing the State Structure -- React Docs: The official guide to avoiding redundant state, mirrored state, and deeply nested structures.
Keep reading