React & State Management: All Concepts

Section 1: Fundamentals

1. Counter with increment, decrement, reset — useState
Remember: prev is needed when the new state depends on the old state. When you're setting a hardcoded value like 0, you just pass 0 directly.
Why: reason prev matters — if multiple state updates happen in the same render cycle, React batches them. Using prev guarantees you're working off the latest state, not a stale snapshot.


2. Toggle show/hide paragraph — useState
Remember:
  • onClick={toggle} — pass the function reference, not the call.
  • onClick={() => toggle()} — also fine, arrow function that calls toggle when clicked.
  • onClick={toggle()} — calls toggle immediately during render, setVisible triggers re-render, toggle calls again, infinite loop.
Why: JSX evaluates expressions inside {} during render. toggle() is an expression that executes immediately. React doesn't get a chance to attach it as a handler — it just runs it right there, state updates, component re-renders, runs again.


3. Controlled input with live character count — controlled components

Controlled

  • React owns the value via useState
  • Always has value prop + onChange handler together
  • Re-renders on every keystroke — state update → new render → input reflects new value
  • Use when: live validation, character count, disabling buttons, dependent fields
  • Angular equivalent: two-way binding

Uncontrolled

  • DOM owns the value, React stays out
  • Use useRef to peek at value when needed (e.g. on submit)
  • No re-render on keystroke — same ref object, mutating .current is invisible to React
  • Use when: simple forms, submit-only validation, performance-sensitive large forms
  • React Hook Form uses this under the hood

Decision rule

  • Need to react to input as user types → controlled
  • Only need value on submit, no live feedback → uncontrolled
  • When in doubt → controlled, easier to extend later


4. Temperature converter — two inputs in sync
  • Two controlled inputs, each with their own state
  • When input A changes → update A's state with raw typed value, update B's state with converted value
  • Never use refs when you need live updates — e.target.value in onChange is enough
  • setState(value) not setState(() => value) — arrow function only needed when using previous state

5. Fetch and display users from JSONPlaceholder: useEffect + fetch
  • Data from fetch must go into useState — plain variables don't trigger re-renderfetch() returns a Response object, not data. Always need .json() to read the body
  • Angular's HttpClient parses automatically — fetch() does not
  • Empty dependency array [] — runs once on mount, never again
  • Always chain: fetch.json()setData

6. Add loading, error, empty states to problem 5
  • Check res.ok before .json() — fetch doesn't throw on 404/500, you must check manually
  • Set error as a string, not an Error object — React can't render objects
  • Three states always: loading, error, data — handle all three in JSX
  • Cleanup flag pattern — set cancelled = true in cleanup function to prevent state updates on unmounted components (this is outdated pattern. Latest pattern is using abort controller)
  • finally for cleanup — runs whether success or error, good place to setLoading(false)
    Why cleanup: 
    What the cleanup flag actually prevents is stale state updates. Without it:
    1. Component fetches user 1
    2. Before it resolves, prop changes to user 2, component re-renders
    3. User 1 response comes back, sets state — now you're showing user 2's page but user 1's data
    4. User 2 response comes back, sets state again — flickers

    The cleanup flag says "if I've been cancelled, ignore whatever comes back." It's a UI correctness problem, not a server resource problem.


8. Colour picker: clicking changes background

Section 2: useEffect Depth

9. Build a fetch with AbortController for request cancellation
  • AbortController actually cancels the in-flight network request — the cancelled flag only prevents stale state updates, it doesn't stop the request
  • Pass signal to fetch: fetch(url, { signal })
  • Call abortController.abort() in the useEffect cleanup function
  • Aborting throws an AbortError — always filter it in catch or you'll set error state on an unmounted component: if(error.name !== 'AbortError') setError(error.message)
  • Use both together for full safety: AbortController cancels the request, filter prevents stale state
  • The cancelled flag pattern is outdated: AbortController is the modern replacement. If you're using AbortController correctly, you don't need the manual cancelled boolean


10. Fetch data when a prop changes: dependency array mechanics

11. Build a window resize listener: add and remove event listener correctly
  • Add event listeners inside useEffect with []: attaches once on mount, not on every render
  • Always extract the handler to a named function: anonymous functions can't be removed because the reference is different every time
  • Cleanup must pass the exact same function reference: removeEventListener("resize", myCallBackFn)
  • window is global:listening once is enough, it stays alive until you remove it
  • useRef to persist values across renders without triggering re-render:use useState if you need the UI to update
  • e.target on resize is the window itself — just use window.innerWidth directly


12. Build a subscription that connects on mount, disconnects on unmount: simulate with setInterval
setInterval returns an ID, store it, pass it to clearInterval in cleanup. Same pattern applies to real WebSockets: socket.connect() on mount, socket.disconnect() in cleanup.


13. Build a component that causes a stale closure bug: then fix it
A closure is when a function "remembers" the variables from where it was created. Stale closure is when that remembered value is outdated because the original variable changed but the function still holds the old snapshot.
let count = 0;
const log = () => console.log(count); // captures count=0
count = 5;
log(); // still logs 0 — stale
React just makes it more visible because useEffect with [] creates a closure once on mount, and state updates create new values rather than mutating — so the closure always holds the first render's snapshot.
Not React's fault. React just exposes it more.
For example, in the below code, if we replaced  setCount((prev) => prev + 1); with  setCount(() => count + 1); it will not work

Section 3: useRef and useCallback

14. Build a timer — start, pause, reset using useRef to store interval ID
  • Store interval ID in useRef: needs to persist across renders without triggering re-render
  • setCount(prev => prev + 1) inside interval — avoids stale closure, never read state directly inside setInterval
  • Guard against multiple intervals: if(intervalId.current) return in start
  • On pause: clearInterval AND set intervalId.current = null: otherwise start is permanently blocked
  • Cleanup in useEffect: return () => clearInterval(intervalId.current): clears if component unmounts while running
  • Pattern applies to any persistent background process: WebSocket, polling, subscriptions

15. Build a search input that debounces API calls 500ms: useRef + useEffect
  • Debounce pattern: clearTimeout before setTimeout on every keystroke — only the last keystroke fires the request
  • Stale closure in setTimeout: don't read state inside the callback: pass the value directly as a parameter
  • AbortController is single-use: once .abort() is called, that controller is dead. Create a fresh one for every new request
  • Cancel previous request before new one: abort the old controller, create new, then fetch
  • fetch signal syntax: fetch(url, { signal }) not fetch(url, signal): must be wrapped in options object
  • Cleanup on unmount: abort the current controller so no stale response sets state after unmount
  • Store controller and timeoutId in useRef: needs to persist across renders without triggering re-render
Full pattern per keystroke:
  • Clear previous timeout
  • Set new timeout
  • On timeout fire → abort previous controller → create new controller → fetch with new signal

16. Fix an unnecessary re-render caused by:
a) Passing a complex object: useMemo
b) Passing a new function reference on every render: useCallback

a) Problem:
a) Using react's memo for simple data types passed as prop (strings, numbers etc)
Before using memo:


After using memo


BUT, For complex data types passed as prop
Example: 
const complexObject = { userInfo: { name: "Hustler!" } };
<UserProfile name={
complexObject} />
Child component:
import { useRef, memo } from "react";

export default memo(function Userprofile({ name }) {
  const countRef = useRef(0);
  countRef.current += 1;
  return (
    <h2>
      Welcome, {name.userInfo.name} [rendered
      {countRef.current} times]
    </h2>
  );
});
Now the memo does NOT work, react keeps re-rendering the child for every change in the parent even if un-related to the child's props.
[IMPORTANT: This is only because the complexObj is a regular const variable and not a useState. Brand new object, brand new memory address. React.memo does a shallow comparison and sees a different reference every time, so it re-renders.
But in useState, React keeps the same object reference across renders unless you explicitly call setName(...). So memo sees the same reference and skips the re-render.]


Solution: useMemo-> Do NOT forget the dependency array, otherwise wont work!
App.js
const complexObject = useMemo(() => ({
      userInfo: {
        name: "Hustler!",
      },
    }),[]);


B) Passing functions as props: useCallback
Note: You need both react.memo AND useCallback
memo: skips re-render if props haven't changed. Works fine for primitives, fails silently for functions/objects because their references always change.
useCallback: stabilizes a function reference across renders. Pairs with memo to actually prevent re-renders when passing functions as props.

memo alone is not enough when props include functions. useCallback alone is pointless without memo on the child. They only work together.

 const cachedFunction = useCallback(() => {
    return "Hustler";
  }, []);
<UserProfile name={cachedFunction} />

Child:
import { useRef, memo } from "react";
export default memo(function UserProfile({ name }) {
  const count = useRef(0);
  count.current = count.current + 1;
  return (
    <h2>Welcome {name()} Rendered {count.current} times</h2>
  );
});


Sumary:
  • Functions as props: stabilize with useCallback
  • Objects/arrays as props: stabilize with useMemo
  • Both need memo on the child to actually prevent re-renders

Section 4: Custom Hooks

Rules of Hooks
Rule 1: Only call hooks at the top level
Never inside conditions, loops, or nested functions. React tracks hooks by call order. If that order changes between renders, state gets mismatched.
Wrong
if(condition) useState(0);

Right
const [count, setCount] = useState(0);
if(condition) { ... }

Rule 2: Only call hooks inside React functions
Never in regular JavaScript functions, class components, or event handlers. Only in functional components or custom hooks.

Rule 3: Custom hooks must start with "use"
This is how React identifies hooks and enforces the above rules. useFetch, useDebounce, useLocalStorage: always use prefix.

Rule 4: Hooks cannot be async
Hooks must run synchronously so React can track them in order. Put async logic inside useEffect or a regular async function called from inside the hook.

Rule 5: Don't call hooks conditionally based on props or state
Same as rule 1: hook call order must be identical on every render.

For custom hooks specifically:
Can call other hooks inside them (that's the whole point)
Must follow all the same rules as built-in hooks
Return whatever the consuming component needs like values, functions, or both
They don't share state between components, each component gets its own instance

17. Build useFetch — reusable data fetching hook with loading and error states


NOTE:
In the parent component, useFetch is directly called instead of useEffect initial mount only. But that is okay. Since custom hook has useEffect that only runs if URL changes.
BUT.. If url was an object instead of a string:
const url = { endpoint: "https://..." }; // new reference every render

useFetch has [url] in its dependency array. Every render in Parent creates a new object at a new memory address. React sees a "changed" dependency every time, fires useEffect in custom hook every render, fetches infinitely.

Why strings don't have this problem: Strings are primitives. Compared by value not reference. "abc" === "abc" is always true. React sees no change, no refetch.

Solution 1: useMemo
const url = useMemo(() => ({ endpoint: "https://..." }), []);
Same object reference across renders. Dependency array sees no change. Fetches once.

Solution 2: useRef
const url = useRef({ endpoint: "https://..." }); // pass url.current to useFetch
Works but less idiomatic for this case. Better suited for mutable values.
Rule: Anything non-primitive in a dependency array needs a stable reference or you risk infinite loops.

18.    Build useDebounce: returns debounced value after delay


Comments

Popular posts from this blog

Inside the JavaScript Memory Box: Visualizing Variables, References, and Copies

React: Communication between components