If you're working with React or React Native, you've probably encountered both useState
and useRef
. At first glance, they might seem similar - both let you store values in your components. But use them interchangeably, and you'll quickly run into confusing bugs or performance issues.
The key difference? useState
triggers re-renders, useRef
doesn't. But there's much more to the story. Let's dive deep into both hooks and understand when to use each one.
useState
is probably the first hook you learned in React. It's the go-to solution for managing data that affects what users see on screen.
When you call useState
, you get back an array with two elements:
const [value, setValue] = useState(initialValue);
value
- The current statesetValue
- A function to update that stateinitialValue
- What the state starts asHere's a classic example:
import React, { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
The crucial thing about useState
: calling setValue
triggers a re-render. This is exactly what you want when data changes and the UI needs to update.
When you click the button:
setCount(count + 1)
is calledcount
value is displayedThis reactive behavior is what makes React... well, React!
useRef
is quite different. It returns a mutable object that persists for the component's entire lifetime:
const myRef = useRef(initialValue);
You get back an object with a single property: current
. You can read from and write to myRef.current
freely, and React won't bat an eye - no re-renders triggered.
The most common use case is getting direct access to DOM elements:
import React, { useRef } from "react";
function TextInputWithFocusButton() {
const inputRef = useRef(null);
const handleClick = () => {
// Directly access the input element
inputRef.current.focus();
};
return (
<>
<input ref={inputRef} type="text" />
<button onClick={handleClick}>Focus the input</button>
</>
);
}
When you attach a ref to a React element with ref={inputRef}
, React sets inputRef.current
to point to the actual DOM node.
You can also use useRef
to store any value that needs to persist but shouldn't trigger re-renders:
import React, { useState, useRef, useEffect } from "react";
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
const start = () => {
if (intervalRef.current !== null) return; // Already running
intervalRef.current = setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
const reset = () => {
stop();
setSeconds(0);
};
// Cleanup on unmount
useEffect(() => {
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
return (
<div>
<p>Time: {seconds}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
<button onClick={reset}>Reset</button>
</div>
);
}
Notice how intervalRef
stores the interval ID. We need this value to persist between renders, but we don't want changing it to cause a re-render - that would be wasteful!
Let's put them side-by-side:
Feature | useState | useRef |
---|---|---|
Purpose | Manage reactive state | Store mutable values without re-rendering |
Returns | [value, setter] |
{ current: value } |
Triggers re-render? | ✅ Yes | ❌ No |
Mutability | Immutable (use setter) | Mutable (change .current directly) |
Persists across renders? | ✅ Yes | ✅ Yes |
Typical use cases | UI state, form data, API results | DOM access, timer IDs, previous values |
The good news? Everything works the same way in React Native!
import React, { useState } from "react";
import { View, Text, TextInput, Button } from "react-native";
function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
return (
<View>
<TextInput value={email} onChangeText={setEmail} placeholder="Email" />
<TextInput
value={password}
onChangeText={setPassword}
placeholder="Password"
secureTextEntry
/>
<Button title="Login" onPress={() => console.log(email, password)} />
</View>
);
}
In React Native, you use refs to access native component methods:
import React, { useRef } from "react";
import { View, TextInput, Button } from "react-native";
function FocusableInput() {
const inputRef = useRef(null);
const focusInput = () => {
inputRef.current?.focus(); // Call native focus method
};
return (
<View>
<TextInput ref={inputRef} placeholder="Type here" />
<Button title="Focus Input" onPress={focusInput} />
</View>
);
}
Or with a ScrollView:
import React, { useRef } from "react";
import { ScrollView, Button, View, Text } from "react-native";
function ScrollableContent() {
const scrollRef = useRef(null);
const scrollToBottom = () => {
scrollRef.current?.scrollToEnd({ animated: true });
};
return (
<View>
<ScrollView ref={scrollRef}>
{/* Your content here */}
<Text>Lots of content...</Text>
</ScrollView>
<Button title="Scroll to Bottom" onPress={scrollToBottom} />
</View>
);
}
A mistake I see often:
// ❌ Bad - This won't update the UI!
function BrokenCounter() {
const countRef = useRef(0);
const increment = () => {
countRef.current += 1;
console.log(countRef.current); // This logs correctly
// But the UI won't update!
};
return (
<div>
<p>Count: {countRef.current}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
// ✅ Good - This updates the UI
function WorkingCounter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Sometimes you need both! Here's a real-world example - tracking whether a component has mounted:
import React, { useState, useRef, useEffect } from "react";
function DataFetcher() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const isMountedRef = useRef(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch("https://api.example.com/data");
const result = await response.json();
// Only update state if component is still mounted
if (isMountedRef.current) {
setData(result);
}
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
};
fetchData();
// Cleanup function
return () => {
isMountedRef.current = false;
};
}, []);
if (loading) return <div>Loading...</div>;
return <div>Data: {JSON.stringify(data)}</div>;
}
Here, isMountedRef
prevents state updates after unmounting (which causes warnings), while data
and loading
manage the UI.
React is smart about re-renders, but you can optimize further:
// Use functional updates when new state depends on old state
setCount((prevCount) => prevCount + 1);
// Use callback version of useState for expensive initialization
const [state, setState] = useState(() => {
const initialState = someExpensiveComputation();
return initialState;
});
Since useRef
doesn't trigger re-renders, there's no performance concern with updating it frequently. That's actually one of its key benefits!
The distinction between useState
and useRef
is fundamental to React:
useState
is for reactive data that affects your UIuseRef
is for non-reactive data and direct element accessThink of useState
as your component's memory for things users see, and useRef
as your component's memory for behind-the-scenes bookkeeping.
Once this clicks, you'll find yourself naturally reaching for the right hook without even thinking about it. And your components will be more efficient and easier to understand.
Now go forth and manage state like a pro! 🚀