Props vs State vs Context vs Ref: When to Use Each in React

Props vs State vs Context vs Ref: When to Use Each in React
Confused about when to use Props, State, Context, or Ref? Misusing them doesn't just slow performance—it creates tangled code that's hard to debug. Let's clear things up once and for all.
Mohammad Alhabil
Author
Props vs State vs Context vs Ref: When to Use Each in React
Props? State? Context? Ref? When should you use each one?
Misusing them doesn't just slow down performance... it creates tangled code that becomes a nightmare to debug later!
Let's sort things out once and for all and understand the differences between them, and when to use each.
1️⃣ Props – The Delivery Mechanism
Think of props like a message from parent to child... you're just delivering, not deciding.
- Data flows from Parent → Child
- Cannot be changed inside the child, only displayed or interacted with
- Use when: The child component just needs to display the value, not modify it
Example
You have a products page, and you're sending each <ProductCard /> information like title and price as props:
// Parent Component
function ProductsPage() {
const products = [
{ id: 1, title: 'Laptop', price: 999 },
{ id: 2, title: 'Phone', price: 699 },
];
return (
<div className="grid grid-cols-3 gap-4">
{products.map(product => (
<ProductCard
key={product.id}
title={product.title} // ← Props going down
price={product.price} // ← Props going down
/>
))}
</div>
);
}
// Child Component
function ProductCard({ title, price }: { title: string; price: number }) {
// Can only READ these values, not change them
return (
<div className="p-4 border rounded">
<h3>{title}</h3>
<p>${price}</p>
</div>
);
}
2️⃣ State – When the Component is in Charge
Think of state like your personal notebook that you write and edit as you please.
- Stores data that can change internally
- Every time it changes, the component re-renders
- Use when: The component manages its own internal state and doesn't share it with others
Example
A modal with an internal form, controlling input values using useState:
function ContactModal() {
// State is OWNED by this component
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
setIsSubmitting(true);
await sendEmail({ name, email });
setIsSubmitting(false);
};
return (
<div className="modal">
<input
value={name}
onChange={(e) => setName(e.target.value)} // Component controls its own state
placeholder="Your name"
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)} // Component controls its own state
placeholder="Your email"
/>
<button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send'}
</button>
</div>
);
}
3️⃣ Context – When Everyone Needs to See the Same Thing
Think of context like electricity... one source that reaches all rooms.
- Pass shared data to multiple components without props drilling
- Use when: You need global state within React, without external libraries
Example
Theme (dark/light) or user data after login:
// 1. Create the Context
const ThemeContext = createContext<{
theme: 'light' | 'dark';
toggleTheme: () => void;
} | null>(null);
// 2. Create the Provider
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 3. Use anywhere in the app!
function Header() {
const { theme, toggleTheme } = useContext(ThemeContext)!;
return (
<header className={theme === 'dark' ? 'bg-gray-900' : 'bg-white'}>
<button onClick={toggleTheme}>
Switch to {theme === 'light' ? 'Dark' : 'Light'} Mode
</button>
</header>
);
}
function Footer() {
const { theme } = useContext(ThemeContext)!;
// Footer also has access to theme without props drilling!
return <footer className={theme === 'dark' ? 'bg-gray-900' : 'bg-white'}>...</footer>;
}
⚠️ Important Warning
Context triggers a re-render for every component that reads from it. If you have rapidly changing data (like a counter or timer), don't use Context as it can hurt performance.
// ❌ BAD - Counter updates every second, re-renders ALL consumers
const TimerContext = createContext(0);
function TimerProvider({ children }) {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => setSeconds(s => s + 1), 1000);
return () => clearInterval(interval);
}, []);
return (
<TimerContext.Provider value={seconds}>
{children} {/* Every consumer re-renders every second! */}
</TimerContext.Provider>
);
}
// ✅ BETTER - Use state locally or a state management library
4️⃣ Ref – When You Need to Deal with Things "As They Are"
Think of ref like a surveillance camera... watching the scene without changing it.
- Stores values without causing re-renders
- Perfect for DOM interaction or keeping stable references
- Use when: You need to keep a value between renders without updating the UI, or interact with DOM directly (like controlling input focus)
Example 1: Auto-focus on Modal Open
function LoginModal({ isOpen }: { isOpen: boolean }) {
const emailInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen && emailInputRef.current) {
emailInputRef.current.focus(); // Direct DOM manipulation
}
}, [isOpen]);
return (
<div className="modal">
<input
ref={emailInputRef} // Attach ref to DOM element
type="email"
placeholder="Enter your email"
/>
<input type="password" placeholder="Password" />
<button>Login</button>
</div>
);
}
Example 2: AbortController for Fetch Requests
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
// Cancel previous request if still pending
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new AbortController
abortControllerRef.current = new AbortController();
const fetchResults = async () => {
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: abortControllerRef.current!.signal,
});
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Search failed:', error);
}
}
};
if (query) fetchResults();
}, [query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<ul>
{results.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</div>
);
}
💡 Want to learn how AbortController doesn't just cancel fetch requests... but can improve performance and solve many problems? Stay tuned for the next post!
The Golden Question
For every variable in your project, ask yourself:
"Who needs to control it? And who needs to see it?"
From that question, you decide where to put it:
| Question | Answer |
|---|---|
| Just needs to display it? | Props |
| Needs to change internally? | State |
| Shared data needed across many components? | Context |
| Need to interact with DOM or keep value without render? | Ref |
Quick Decision Flowchart
Is the data coming from a parent?
├── YES → Use Props
└── NO → Does it need to trigger re-renders when changed?
├── YES → Do multiple components need it?
│ ├── YES → Use Context (or state management)
│ └── NO → Use State
└── NO → Use Ref
⚠️ Anti-Pattern: Passing setState as Props
// ❌ BAD - Passing setState directly
function Parent() {
const [user, setUser] = useState(null);
return <LoginForm setUser={setUser} />; // Fragile!
}
// ✅ BETTER - Wrap in a custom hook or Context
function useAuth() {
const [user, setUser] = useState(null);
const login = async (credentials) => {
const user = await authenticate(credentials);
setUser(user);
};
const logout = () => setUser(null);
return { user, login, logout };
}
// Or use Context
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const auth = useAuth();
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}
Why is passing setState directly a bad practice?
- ❌ Fragile code that's hard to track
- ❌ Difficult to modify long-term
- ❌ Breaks encapsulation
- ❌ Makes testing harder
Summary Comparison Table
| Feature | Props | State | Context | Ref |
|---|---|---|---|---|
| Direction | Parent → Child | Internal | Global | Internal |
| Causes Re-render | When parent changes | Yes | Yes (all consumers) | No |
| Mutable | No | Yes (via setter) | Yes (via setter) | Yes (directly) |
| Best For | Passing data down | Local UI state | Shared global state | DOM/values without render |
Pro Tips
- Start with props – Only elevate to state/context when needed
- Keep state close – Put state as close to where it's used as possible
- Split contexts – Don't put everything in one giant context
- Memoize context values – Prevent unnecessary re-renders
- Use refs sparingly – They're escape hatches, not the default
Interfaces you love, code you understand. 💡
Topics covered
Found this article helpful?
Share it with your network and help others learn too!

Written by Mohammad Alhabil
Frontend Developer & Software Engineer passionate about building beautiful and functional web experiences. I write about React, Next.js, and modern web development.
Related Articles
View all
Managing SVG Icons in React & Next.js: The Complete Guide
Icons from different sources? Legacy SVGs as images? Learn how to properly manage, organize, and use SVG icons in modern React and Next.js projects.

Understanding React Re-renders: Virtual DOM, Diffing & Reconciliation
We're all afraid of re-renders. But is this fear justified? Learn how React actually handles changes under the hood and why not every render is a disaster.

Why useState Isn't Always the Answer: URL State vs React State
There's a simple pattern that can change how you handle UI, achieve better performance, maintain SEO, and create a cleaner interface. Learn when to use URL instead of useState.