React State and Hooks: The Complete Guide
March 3, 2025 · 10 min read
React, Hooks, JavaScript, State Management, Interview Prep
Introduction
Hooks revolutionized React by bringing state and lifecycle features to functional components. Understanding hooks deeply is essential for writing production React code. This article covers the core hooks, their rules, and patterns you'll use daily.
useState: Managing Component State
useState is the foundation of state management in functional components.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(prev => prev - 1)}>Decrement</button>
</div>
);
}
Understanding the Destructuring
setCount is a function that React creates for you - you're just naming it via array destructuring. You could call it anything:
const [count, updateCount] = useState(0);
const [name, setName] = useState('');
const [items, setItems] = useState([]);
The convention is [value, setValue] but it's just a naming pattern, not a requirement.
For C# Developers
If you're coming from C#, useState is similar to a property with INotifyPropertyChanged:
// C# with INotifyPropertyChanged (WPF/MAUI)
private int _count;
public int Count
{
get => _count;
set { _count = value; OnPropertyChanged(); }
}
// React equivalent
const [count, setCount] = useState(0);
// setCount(5) triggers re-render automatically
Key difference: In C#, you mutate the value and notify. In React, you never mutate - you always replace with a new value via the setter function.
Functional Updates
When new state depends on previous state, use the functional form:
// ❌ Can cause bugs with rapid updates
setCount(count + 1);
setCount(count + 1); // Still uses stale count!
// ✅ Always gets latest state
setCount(prev => prev + 1);
setCount(prev => prev + 1); // Correctly increments twice
Why Functional Updates Matter
React batches state updates for performance. When you call setCount(count + 1) twice, both calls see the same count value. The functional form prev => prev + 1 always receives the most recent state.
Complex State
function UserForm() {
const [user, setUser] = useState({
name: '',
email: '',
preferences: { theme: 'light', notifications: true }
});
const updateField = (field, value) => {
setUser(prev => ({
...prev,
[field]: value
}));
};
const updatePreference = (key, value) => {
setUser(prev => ({
...prev,
preferences: {
...prev.preferences,
[key]: value
}
}));
};
return (
<form>
<input
value={user.name}
onChange={e => updateField('name', e.target.value)}
/>
{/* ... */}
</form>
);
}
Lazy Initialization
For expensive initial state calculations:
// ❌ Runs on every render
const [data, setData] = useState(expensiveCalculation());
// ✅ Only runs once on mount
const [data, setData] = useState(() => expensiveCalculation());
useEffect: Side Effects and Lifecycle
useEffect handles side effects: data fetching, subscriptions, DOM manipulation, and more.
useEffect(() => {
// Effect runs after render
return () => {
// Cleanup runs before next effect or unmount
};
}, [dependencies]);
Dependency Array Patterns
// Runs after every render
useEffect(() => {
console.log('Rendered');
});
// Runs once on mount, cleanup on unmount
useEffect(() => {
const subscription = api.subscribe();
return () => subscription.unsubscribe();
}, []);
// Runs when userId changes
useEffect(() => {
fetchUser(userId);
}, [userId]);
Data Fetching Pattern
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchUser() {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!cancelled) {
setUser(data);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchUser();
return () => {
cancelled = true;
};
}, [userId]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
return <Profile user={user} />;
}
Race Condition Prevention
The cancelled flag prevents setting state after the component unmounts or when a newer request should take precedence. This is a common source of bugs and memory leaks.
Event Listeners
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return size;
}
useRef: Mutable References
useRef provides a mutable container that persists across renders without causing re-renders.
DOM References
function TextInput() {
const inputRef = useRef(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<>
<input ref={inputRef} />
<button onClick={focusInput}>Focus</button>
</>
);
}
Storing Mutable Values
function Timer() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
const start = () => {
if (intervalRef.current) return;
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);
return (
<div>
<p>{seconds} seconds</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
Previous Value Pattern
What it does: Stores the previous value of a state or prop, giving you access to what it was before the current render.
Why use it: Comparing current vs previous values - animations based on direction of change, detecting if a specific prop changed, undo functionality.
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
// Usage
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<p>Current: {count}, Previous: {prevCount}</p>
);
}
Rules of Hooks
These Rules Are Non-Negotiable
Breaking these rules causes bugs that are hard to debug. React relies on hook call order to maintain state correctly.
1. Only Call Hooks at the Top Level
// ❌ WRONG - conditional hook
function Bad({ condition }) {
if (condition) {
const [value, setValue] = useState(0); // Breaks hook order!
}
}
// ✅ CORRECT - always call, conditionally use
function Good({ condition }) {
const [value, setValue] = useState(0);
if (condition) {
// Use value here
}
}
2. Only Call Hooks from React Functions
// ❌ WRONG - regular function
function regularFunction() {
const [state, setState] = useState(0); // Not allowed!
}
// ✅ CORRECT - React component or custom hook
function useCustomHook() {
const [state, setState] = useState(0);
return [state, setState];
}
Custom Hooks
Custom hooks extract reusable logic. They're just functions that use other hooks.
useLocalStorage
What it does: Syncs state with browser's localStorage, so data persists across page refreshes and browser sessions.
Why use it: User preferences (theme, language), form drafts, shopping cart items - anything you want to survive a page reload without a backend.
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const setValue = (value) => {
try {
const valueToStore = value instanceof Function
? value(storedValue)
: value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// Usage
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
);
}
useDebounce
What it does: Delays updating a value until the user stops changing it for a specified time (e.g., 300ms).
Why use it: Search inputs. Without debouncing, typing "react hooks" fires 11 API calls (one per keystroke). With debouncing, it waits until you stop typing, then fires once. Saves API calls and improves performance.
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Usage
function Search() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
searchApi(debouncedQuery);
}
}, [debouncedQuery]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}
Custom Hooks Are Your Secret Weapon
Building Glucoplate, I found custom hooks invaluable for keeping components clean. Hooks like useDebounce and useLocalStorage get reused constantly. Extract logic into hooks early - it pays off quickly.
useToggle
What it does: Manages boolean state with convenient toggle/setTrue/setFalse functions.
Why use it: Modals, dropdowns, sidebars, any show/hide UI. Cleaner than writing setIsOpen(!isOpen) everywhere.
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => {
setValue(v => !v);
}, []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
// Usage
function Modal() {
const { value: isOpen, toggle, setFalse: close } = useToggle();
return (
<>
<button onClick={toggle}>Open Modal</button>
{isOpen && <ModalContent onClose={close} />}
</>
);
}
Questions Worth Considering
Q1: What's the difference between useEffect and useLayoutEffect?
Answer:
useEffectruns asynchronously after the browser paintsuseLayoutEffectruns synchronously before the browser paints
Use useLayoutEffect when you need to measure DOM elements or make visual changes that should happen before the user sees anything. For most cases, useEffect is preferred to avoid blocking the paint.
Q2: Why can't hooks be called conditionally?
Answer: React tracks hooks by their call order. Each render must call hooks in the exact same order. If a hook is called conditionally, the order could change between renders, causing React to associate the wrong state with the wrong hook.
Q3: How do you prevent useEffect from running on initial render?
Answer: Use a ref to track if it's the first render:
function useUpdateEffect(effect, deps) {
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
return effect();
}, deps);
}
Q4: What causes infinite loops with useEffect?
Answer: Creating new objects/arrays in the dependency array or updating state that's in the dependency array:
// ❌ Infinite loop - object recreated every render
useEffect(() => {
fetch(options); // options is { url: '...' }
}, [options]); // New object each time!
// ✅ Fix - stable reference or primitive deps
const url = options.url;
useEffect(() => {
fetch({ url });
}, [url]);
Common Mistakes
1. Missing Dependencies
// ❌ ESLint will warn - missing userId
useEffect(() => {
fetchUser(userId);
}, []); // userId should be in deps
// ✅ CORRECT
useEffect(() => {
fetchUser(userId);
}, [userId]);
2. Object/Array Dependencies
// ❌ Creates new object every render
useEffect(() => {
doSomething(config);
}, [{ key: value }]); // Always "new"
// ✅ Use primitive values or useMemo
const config = useMemo(() => ({ key: value }), [value]);
useEffect(() => {
doSomething(config);
}, [config]);
3. Stale Closures
// ❌ count is stale in the interval
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // Always uses initial count!
}, 1000);
return () => clearInterval(id);
}, []);
// ✅ Use functional update
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // Always gets current value
}, 1000);
return () => clearInterval(id);
}, []);
Summary
- useState manages local component state with functional updates for derived state
- useEffect handles side effects with proper cleanup and dependency tracking
- useRef stores mutable values without triggering re-renders
- Rules of Hooks must be followed: top-level only, React functions only
- Custom hooks extract and share stateful logic across components
Next up: Component Patterns - composition, compound components, render props, and HOCs.
Part 2 of the React Developer Reference series.