React isn't hard — but it's easy to misunderstand. This guide goes from real questions to real answers, explaining why things work the way they do, not just how to use them.
What is React?
React is a JavaScript library built by Meta for building user interfaces using a component model — splitting the UI into small, reusable pieces.
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
useState — Managing State
useState stores data in the browser's RAM, in a region React manages internally. Each component has its own list of slots — React uses the order of hook calls to know which slot belongs to which useState.
const [count, setCount] = useState(0);
Why use the callback form prev => prev + 1?
Because setState doesn't update immediately — React processes updates in batches. If you call it multiple times in a row:
// ❌ Called 3 times → only increments by 1, because all calls use the same stale snapshot
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// ✅ Called 3 times → correctly increments by 3, because prev is always the latest value
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
If you only call it once,
setCount(count + 1)andsetCount(prev => prev + 1)produce the same result.
Why must you copy objects and arrays?
React compares state by memory address, not by content. Mutating directly doesn't create a new address → React sees no change → no re-render.
// ❌ Same address → no re-render
user.age = 21;
setUser(user);
// ✅ Brand new object with a new address → React sees the change → re-render
setUser({ ...user, age: 21 });
Where does useState actually store data?
In RAM — it's lost on page reload, lost when navigating away (if the component unmounts), and lost when the tab is closed. For longer-lived storage:
| Storage | Survives Back | Survives Reload | Shareable URL |
|---|---|---|---|
useState in component |
❌ | ❌ | ❌ |
| URL params | ✅ | ✅ | ✅ |
localStorage |
✅ | ✅ | ❌ |
bfcache note: Modern browsers (Chrome, Firefox, Safari) freeze the entire page — including React state — when you navigate away, and restore it when you hit Back. This is why navigation sometimes feels like state is preserved even without URL params. It's the browser doing the work, not React.
useEffect — Running Code Outside the Render
Side effects are operations outside of rendering UI: fetching data, setting timers, listening to events...
useEffect(() => {
// code runs here
return () => {
// cleanup — React STORES this function, it doesn't run immediately
// It only runs when: a dependency changes, or the component unmounts
};
}, [dependencies]);
return () => ... is not sequential code
This is the most commonly misunderstood part. The return here hands a function to React — it doesn't call it immediately. React decides when to call it.
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value); // ← a setTimeout callback, React doesn't care about this
}, delay);
return () => clearTimeout(timer); // ← gives React a function to store
}, [value]);
// Cleanup runs when value changes — NOT when setTimeout finishes
3 common forms
useEffect(() => { ... }, []); // once on mount
useEffect(() => { ... }, [x]); // whenever x changes
useEffect(() => { ... }); // after every render (rarely used)
useRef — Values Without Re-renders
Two main uses:
// 1. Direct DOM access
const inputRef = useRef(null);
inputRef.current.focus();
// 2. Storing internal values (timer IDs, render counts...)
const timerRef = useRef(null);
timerRef.current = setInterval(...);
clearInterval(timerRef.current);
useState |
useRef |
|
|---|---|---|
| Triggers re-render | ✅ | ❌ |
| Use for | Data displayed in UI | Internal data, not displayed |
useContext — Avoiding Prop Drilling
When you find yourself passing props through multiple layers of components that don't use them:
// Create the context
const UserContext = createContext(null);
// Wrap the app
<UserContext.Provider value={{ user, setUser }}>
<App />
</UserContext.Provider>
// Consume it anywhere — no prop passing needed
const { user } = useContext(UserContext);
The downside: Every component consuming the context re-renders when any value in it changes. Solution: split contexts into smaller ones, or use Zustand.
useMemo and useCallback — Performance Optimization
// Cache the result of an expensive computation
const results = useMemo(() => {
return list.filter(x => x.name.includes(query));
}, [list, query]);
// Cache a function passed to a child component (use with React.memo)
const handleClick = useCallback(() => {
setCount(prev => prev + 1);
}, []);
Don't overuse these. Both
useMemoanduseCallbackconsume memory to maintain the cache. Only reach for them when you have a real, measurable performance problem.
Custom Hooks — Reusable Logic
A function starting with use that uses other hooks internally:
// hooks/useFetch.js
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(url)
.then(r => r.json())
.then(data => { setData(data); setLoading(false); })
.catch(err => { setError(err.message); setLoading(false); });
}, [url]);
return { data, loading, error };
}
// hooks/useDebounce.js
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // cancel stale timer when value changes
}, [value, delay]);
return debounced;
}
The mental model: components handle UI, hooks handle logic.
Zustand — Simple State Management
Fixes the main weakness of useContext — components only re-render when the specific slice of data they subscribed to actually changes.
// store/useCounterStore.js
import { create } from 'zustand';
const useCounterStore = create((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
reset: () => set({ count: 0 }),
}));
// Use anywhere — no Provider needed
const count = useCounterStore(s => s.count); // re-renders when count changes
const increment = useCounterStore(s => s.increment); // never re-renders
TanStack Query — Professional Data Fetching
Instead of rewriting loading/error state every time, TanStack Query handles it all:
const { data, isLoading, error, isFetching } = useQuery({
queryKey: ['posts', page], // cached by key — change it → auto-refetches
queryFn: () => fetch(`/api/posts?page=${page}`).then(r => r.json()),
});
Automatic: result caching, retry on failure, refetch on tab focus.
// Mutating data (POST/PUT/DELETE)
const mutation = useMutation({
mutationFn: (data) => fetch('/api/posts', { method: 'POST', body: JSON.stringify(data) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['posts'] }),
});
isLoading |
isFetching |
|
|---|---|---|
| First fetch, no data yet | ✅ | ✅ |
| Refetch (stale data shown) | ❌ | ✅ |
Next.js App Router
By default, every component is a Server Component — runs on the server, fetches data directly, no useEffect needed.
// Server Component — no "use client" needed
export default async function BlogPage({ searchParams }) {
const page = Number(searchParams.page) || 1;
const posts = await fetch(`/api/posts?page=${page}`).then(r => r.json());
return <PostList posts={posts} />;
}
To use useState, useEffect, onClick → you must add "use client":
"use client";
import { useState } from 'react';
export default function SearchBar() {
const [query, setQuery] = useState('');
return <input onChange={e => setQuery(e.target.value)} />;
}
The canonical search + pagination pattern
Client Component (SearchBar, Pagination)
→ receives input → pushes to URL (?q=nextjs&page=2)
Server Component (page.jsx)
→ reads URL → fetches data → renders results
Next.js only re-renders the parts that changed — no full page reload. The experience feels as smooth as a SPA, but data is always fresh from the server.
Server Component vs Client Component — quick rule
| Need | Use |
|---|---|
| Fetch data on first load | Server Component |
useState, useEffect
|
"use client" |
onClick, onChange
|
"use client" |
| SEO, fast initial load | Server Component |
| User interaction, real-time UI | "use client" |
The Decision Tree
| Situation | Solution |
|---|---|
| Local state that affects the UI | useState |
| API calls, timers, event listeners | useEffect |
| DOM access, internal values | useRef |
| Sharing state, avoiding prop drilling |
useContext or Zustand |
| Expensive computations, caching callbacks |
useMemo, useCallback
|
| Reusable logic across components | Custom Hook |
| App-wide state (user, cart, UI) | Zustand |
| Data fetching with caching and refetch | TanStack Query |
| Static data fetching, SEO | Next.js Server Component |
| Search/pagination that survives reload | URL params |
Top comments (0)