DEV Community

Lộc Trương
Lộc Trương

Posted on • Originally published at locionic.com

React in Practice: Understanding Hooks, State Management, and Next.js App Router

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>;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

If you only call it once, setCount(count + 1) and setCount(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 });
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

3 common forms

useEffect(() => { ... }, []);    // once on mount
useEffect(() => { ... }, [x]);   // whenever x changes
useEffect(() => { ... });        // after every render (rarely used)
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
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);
Enter fullscreen mode Exit fullscreen mode

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);
}, []);
Enter fullscreen mode Exit fullscreen mode

Don't overuse these. Both useMemo and useCallback consume 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 };
}
Enter fullscreen mode Exit fullscreen mode
// 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;
}
Enter fullscreen mode Exit fullscreen mode

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 }),
}));
Enter fullscreen mode Exit fullscreen mode
// 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
Enter fullscreen mode Exit fullscreen mode

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()),
});
Enter fullscreen mode Exit fullscreen mode

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'] }),
});
Enter fullscreen mode Exit fullscreen mode
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} />;
}
Enter fullscreen mode Exit fullscreen mode

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)} />;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)