section3_forms_data_fetching.md

Section 3: Forms & Data Fetching — Deep Dive

This section is where React meets real-world user interaction. Every app has forms and API calls — interviewers test whether you understand the mechanics (controlled vs uncontrolled), the pitfalls (race conditions, stale closures), and the safety nets (error boundaries).


3.1 Controlled vs Uncontrolled Components

The Core Idea

The question is simple: who owns the input's value — React or the browser?

ControlledUncontrolled
Source of truthReact state (useState)The DOM element itself
How you read the valueFrom the state variableVia a ref at submit time
Re-renders on keystroke?Yes (every onChange)No
Live validation possible?✅ Yes❌ Not practically

🔍 Your Code: Controlled Form — Login.jsx

// Login.jsx — Lines 7-8: each field gets its own state slice const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); // Line 60-61: value is ALWAYS driven by state, onChange pushes back to state <input type="email" value={email} // ← React owns the value onChange={(e) => setEmail(e.target.value)} // ← every keystroke updates state required />

What happens on every keystroke:

  1. User presses a key → browser fires onChange
  2. setEmail(e.target.value) schedules a re-render
  3. React re-renders, passes the new state value back to value={email}
  4. The input displays whatever React says

[!IMPORTANT] This is a circular loop: Input → State → Re-render → Input. If you forget onChange, the input becomes frozen — you type but nothing appears, because React keeps forcing value back to the old state.


🔍 Your Code: Controlled Form with Object State — CreateLink.jsx

// CreateLink.jsx — Lines 9-17: one state object for all fields const [formData, setFormData] = useState({ originalUrl: "", slug: "", title: "", description: "", tags: "", password: "", expiresAt: "", }); // Lines 23-25: a single generic handler for all inputs const handleChange = (e) => { setFormData({ ...formData, [e.target.name]: e.target.value }); }; // Lines 85-96: every input uses the same handler via name attribute <input type="url" name="originalUrl" value={formData.originalUrl} onChange={handleChange} />

Why this pattern is smart:

Instead of 7 separate useState + 7 separate setXxx calls, you use one state object and a computed property name [e.target.name] to update the right field. This scales well for forms with many fields.

The subtle danger:

// ❌ This has a stale closure bug under concurrent updates: setFormData({ ...formData, [e.target.name]: e.target.value }); // ✅ Safer: use the functional updater form: setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));

With the first version, if two handleChange calls fire in the same batch, both read the same old formData from closure and the second one overwrites the first's change. The functional form always operates on the latest state.

[!TIP] Interview line: "I'd use the functional updater prev => ({...prev, ...}) for object state to avoid stale closure bugs during batched updates."


📝 Uncontrolled Example (Not in your code, but you need to know it)

import { useRef } from 'react'; function QuickFeedbackForm() { const feedbackRef = useRef(null); const handleSubmit = (e) => { e.preventDefault(); // Read the value only at submit time — no state, no re-renders const feedback = feedbackRef.current.value; console.log('Submitted:', feedback); feedbackRef.current.value = ''; // manually reset the DOM }; return ( <form onSubmit={handleSubmit}> <textarea ref={feedbackRef} defaultValue="" placeholder="Your feedback..." /> <button type="submit">Send</button> </form> ); }

Key differences from controlled:

  • defaultValue instead of value (lets the DOM manage the value)
  • ref instead of onChange + state
  • Zero re-renders while the user types
  • You lose the ability to validate live, disable buttons based on content, or show character counts

📝 File Input — Always Uncontrolled

function AvatarUpload() { const fileRef = useRef(null); const handleUpload = () => { const file = fileRef.current.files[0]; // read from DOM if (file) { const formData = new FormData(); formData.append('avatar', file); // send to server... } }; return ( <> {/* ❌ You CANNOT do: value={someState} on a file input */} {/* Browsers block setting file values programmatically for security */} <input type="file" ref={fileRef} accept="image/*" /> <button onClick={handleUpload}>Upload</button> </> ); }

[!CAUTION] Interview trap: "Can you make a file input controlled?" — No. Browsers prevent setting a file input's value programmatically (imagine a malicious site auto-selecting passwords.txt from your desktop). You can only read the selected file via ref or e.target.files.


🎯 When to Use Which — Decision Framework

Do I need to react to the input WHILE the user types? ├── YES → Controlled (useState + value + onChange) │ Examples: live search/filter, character count, "passwords must match" cross-field validation, │ disabling submit until form is valid └── NO → Uncontrolled (useRef + defaultValue) Examples: simple feedback form, file upload, performance-sensitive form with 50+ fields

🔥 Frequently Asked Interview Questions

Q: "Your controlled input is frozen — user types but nothing appears. Why?"

A: Missing or broken onChange handler. React keeps forcing value back to the unchanged state. Without onChange calling setState, the state never updates, so the input always shows the old value.

Q: "Can you have a mix of controlled and uncontrolled in the same form?"

A: Yes, but React will warn if you switch a single input from controlled to uncontrolled (or vice versa) mid-lifecycle — e.g., value={undefined} after previously having value={someString}. The warning reads: "A component is changing a controlled input to be uncontrolled."

Q: "Performance-wise, is controlled a problem?"

A: For typical forms (5-10 fields), the re-render cost is negligible. For very large forms (50+ fields) where every keystroke re-renders the entire form, either (a) use uncontrolled + refs, (b) isolate each input into its own component with its own state, or (c) use react-hook-form (which uses uncontrolled inputs internally).


3.2 Form Validation

The Principle

Validation lives in two places, for two different reasons:

  • Client-side → instant feedback, better UX
  • Server-side → actual security, because the client can always be bypassed (curl, Postman, disabled JS)

🔍 Your Code: Server Error Handling — CreateLink.jsx

// CreateLink.jsx — Lines 48-55 } catch (error) { toast.error(error.response?.data?.message || "Failed to create link"); // Server returns per-field errors — display each one if (error.response?.data?.errors) { const errs = error.response.data.errors; Object.keys(errs).forEach(key => { toast.error(`${key}: ${errs[key].join(", ")}`); }); } }

This is server-side validation surfaced on the client. Your backend (Prisma/Express) validates the data and returns structured field-level errors. The client just displays them. This is correct, but there's no client-side validation happening before the request — the user has to wait for a round trip to see "invalid URL."


📝 Adding Client-Side Validation (Manual Pattern)

Here's how you'd add live validation to CreateLink without a library:

function CreateLinkWithValidation() { const [formData, setFormData] = useState({ originalUrl: '', slug: '' }); const [errors, setErrors] = useState({}); const [touched, setTouched] = useState({}); // track which fields user has interacted with const validate = (data) => { const errs = {}; // URL validation try { new URL(data.originalUrl); } catch { errs.originalUrl = 'Enter a valid URL (include https://)'; } // Slug format if (data.slug && !/^[a-z0-9-]+$/.test(data.slug)) { errs.slug = 'Slug can only contain lowercase letters, numbers, and hyphens'; } return errs; }; const handleChange = (e) => { const updated = { ...formData, [e.target.name]: e.target.value }; setFormData(updated); // Validate only touched fields (don't yell before the user finishes typing) if (touched[e.target.name]) { setErrors(validate(updated)); } }; const handleBlur = (e) => { setTouched(prev => ({ ...prev, [e.target.name]: true })); setErrors(validate(formData)); // validate on blur }; const handleSubmit = (e) => { e.preventDefault(); const allErrors = validate(formData); setErrors(allErrors); setTouched({ originalUrl: true, slug: true }); // mark all as touched if (Object.keys(allErrors).length > 0) return; // don't submit // ... proceed with API call }; return ( <form onSubmit={handleSubmit}> <input name="originalUrl" value={formData.originalUrl} onChange={handleChange} onBlur={handleBlur} /> {touched.originalUrl && errors.originalUrl && ( <span className="error">{errors.originalUrl}</span> )} <button type="submit" disabled={Object.keys(errors).length > 0} > Create </button> </form> ); }

Key patterns:

  • touched state: prevents showing errors before the user has interacted with a field
  • onBlur validation: validate when the user leaves a field (not on every keystroke — less noisy)
  • Submit-time validation: validate everything, mark all fields as touched, block submit if errors exist

📝 react-hook-form (Library Approach — Know for Interview)

import { useForm } from 'react-hook-form'; function CreateLinkRHF() { const { register, // connects inputs (uncontrolled internally) handleSubmit, // wraps your submit handler with validation formState: { errors, isSubmitting } } = useForm(); const onSubmit = async (data) => { // data is already validated — just send to API await api.post('/api/v1/create-link', data); }; return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('originalUrl', { required: 'URL is required', pattern: { value: /^https?:\/\/.+/, message: 'Must be a valid URL starting with http(s)://' } })} /> {errors.originalUrl && <span>{errors.originalUrl.message}</span>} <input {...register('slug', { pattern: { value: /^[a-z0-9-]*$/, message: 'Only lowercase, numbers, hyphens' } })} /> {errors.slug && <span>{errors.slug.message}</span>} <button disabled={isSubmitting}>Create</button> </form> ); }

Why react-hook-form is faster: It uses uncontrolled inputs internally (registers refs, not state), so typing doesn't trigger re-renders. Only validation errors cause re-renders. For a form with 20 fields, this is a significant perf win.

[!TIP] Interview line: "react-hook-form gives you the UX of controlled (live validation, error messages) with the performance of uncontrolled (no re-render per keystroke), by using refs internally and only re-rendering when validation state changes."


🔥 Interview Questions

Q: "Where should validation live?"

A: Both. Client-side for instant UX feedback; server-side for security — the client can always be bypassed. Never trust the client alone.

Q: "Your form shows an error before the user even starts typing. How do you fix it?"

A: Track touched state per field. Only show errors for fields the user has interacted with (via onBlur or after first submit attempt).

Q: "What's the advantage of react-hook-form over manual validation?"

A: Performance (uncontrolled internally = no re-render per keystroke), less boilerplate (no manual errors/touched state), integrates with schema validators (zod/yup) for complex rules.


3.3 Data Fetching with useEffect (and its Pitfalls)

This is the #1 most probed topic in React interviews. Interviewers want to see if you understand the failure modes, not just the happy path.


🔍 Your Code: Basic Fetch Pattern — Home.jsx

// Home.jsx — Lines 25-41 useEffect(() => { const fetchData = async () => { try { const [userRes, linksRes] = await Promise.all([ api.get("/api/v1/me"), api.get("/api/v1/my-links"), ]); setName(userRes.data.user.name); setLinks(linksRes.data.links); } catch { toast.error("Failed to load data"); } finally { setLinksLoading(false); } }; fetchData(); }, []); // ← empty array = runs once on mount

What's going on here:

  1. useEffect(..., []) — the empty dependency array means "run this once after the first render" (like componentDidMount)
  2. We can't make useEffect's callback itself async (it must return undefined or a cleanup function, not a Promise), so we define an async inner function and call it immediately
  3. Promise.all fires both requests in parallel — faster than sequential awaits
  4. finally ensures loading state clears even if the request fails

[!NOTE] Why Promise.all instead of two separate awaits? If each request takes 200ms, sequential awaits take ~400ms total. Promise.all fires both simultaneously, taking ~200ms (the slower one). Your code does this correctly.


🔍 Your Code: Data Fetching with Dependencies — LinkAnalytics.jsx

// LinkAnalytics.jsx — Lines 25-52 const { id } = useParams(); const [range, setRange] = useState("7d"); useEffect(() => { const fetchAllData = async () => { setLoading(true); try { const [anaRes, timeRes, countRes, refRes, devRes] = await Promise.all([ api.get(`/api/v1/analytics/${id}`), api.get(`/api/v1/analytics/${id}/timeseries?range=${range}&tz=...`), api.get(`/api/v1/analytics/${id}/countries`), api.get(`/api/v1/analytics/${id}/referrers`), api.get(`/api/v1/analytics/${id}/devices`), ]); setAnalytics(anaRes.data); setTimeseries(timeRes.data.data); // ... etc } catch (error) { toast.error("Failed to load analytics data"); } finally { setLoading(false); } }; fetchAllData(); }, [id, range]); // ← re-fetches when the link ID or time range changes

The dependency array [id, range] means:

  • When the user navigates to a different link (id changes) → re-fetch
  • When the user picks a different time range (range changes) → re-fetch
  • On any other re-render (parent re-renders, unrelated state changes) → effect does NOT re-run

⚠️ Pitfall 1: Race Conditions

The problem your code has:

If the user rapidly switches between "7d" → "30d" → "today", three requests fire. But network responses don't arrive in order — the "7d" request might resolve after "today" and overwrite the chart with stale data.

Your code has NO guard against this. Here's how to fix it:

useEffect(() => { let cancelled = false; // ← flag to ignore stale responses const fetchAllData = async () => { setLoading(true); try { const [anaRes, timeRes] = await Promise.all([ api.get(`/api/v1/analytics/${id}`), api.get(`/api/v1/analytics/${id}/timeseries?range=${range}`), ]); if (!cancelled) { // ← only update state if this effect is still "current" setAnalytics(anaRes.data); setTimeseries(timeRes.data.data); } } catch (error) { if (!cancelled) toast.error("Failed to load analytics data"); } finally { if (!cancelled) setLoading(false); } }; fetchAllData(); return () => { cancelled = true; }; // ← cleanup: mark as stale }, [id, range]);

How this works:

  1. User selects "7d" → effect runs, cancelled = false, request fires
  2. User quickly selects "30d" → React runs cleanup from step 1 (cancelled = true), then runs the new effect with its own cancelled = false
  3. When the "7d" response finally arrives, cancelled is true in its closure → setState is skipped
  4. The "30d" response arrives, its cancelled is still false → state updates correctly

📝 Even Better: AbortController (Cancels the HTTP Request Itself)

useEffect(() => { const controller = new AbortController(); const fetchData = async () => { setLoading(true); try { const res = await fetch(`/api/analytics/${id}`, { signal: controller.signal // ← pass the abort signal }); const data = await res.json(); setAnalytics(data); } catch (error) { if (error.name !== 'AbortError') { // Only show error for real failures, not intentional cancellations toast.error("Failed to load data"); } } finally { setLoading(false); } }; fetchData(); return () => controller.abort(); // ← actually cancels the HTTP request }, [id]);

Difference from the cancelled flag:

  • cancelled flag: the request still completes, you just ignore the response → wastes bandwidth
  • AbortController: the browser cancels the HTTP request entirely → saves bandwidth and server resources

With axios (which your project uses), the equivalent is:

useEffect(() => { const controller = new AbortController(); api.get(`/api/v1/analytics/${id}`, { signal: controller.signal }) .then(res => setAnalytics(res.data)) .catch(err => { if (!axios.isCancel(err)) toast.error("Failed"); }); return () => controller.abort(); }, [id]);

⚠️ Pitfall 2: Missing Dependency (Stale Closure)

// ❌ BUG: range is used inside but not in the dependency array useEffect(() => { fetch(`/api/analytics/${id}?range=${range}`) .then(r => r.json()) .then(data => setAnalytics(data)); }, [id]); // ← range is missing!

What happens: When the user changes range, the effect doesn't re-run. But if id changes later, the effect runs — using whatever range was when the closure was first created, not the current one. This is a stale closure bug.

Fix: Always include every reactive value used inside the effect in the dependency array:

}, [id, range]); // ← your LinkAnalytics.jsx does this correctly ✅

[!WARNING] The eslint-plugin-react-hooks rule (exhaustive-deps) catches this automatically. Never disable it without understanding why.


⚠️ Pitfall 3: No Loading/Error States

// ❌ Fragile: no feedback while loading, silent failures useEffect(() => { fetch('/api/links').then(r => r.json()).then(data => setLinks(data)); }, []);

Proper pattern (your code does this well):

const [loading, setLoading] = useState(true); // start loading const [error, setError] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); setError(null); fetch('/api/links') .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) .then(data => { if (!cancelled) setLinks(data); }) .catch(err => { if (!cancelled) setError(err.message); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, []); // In JSX: if (loading) return <Spinner />; if (error) return <ErrorMessage message={error} />; return <LinkList data={links} />;

🔍 Your Code: Conditional Loading UI — LinkAnalytics.jsx

// Lines 54-68: smart loading check if (loading && !analytics) { return <div className="page-shell flex items-center justify-center"> <div className="w-5 h-5 rounded-full animate-spin" ... /> </div>; } if (!analytics) return null;

Why loading && !analytics? This is a nice pattern: on the first load, show a full-page spinner. On subsequent re-fetches (when range changes), analytics already has data, so the page stays visible with just the chart area showing a local spinner (line 140-149). This prevents the jarring flash of a blank page on every filter change.


🔍 Your Code: useEffect for Syncing Props to State — EditLinkModal.jsx

// EditLinkModal.jsx — Lines 14-28 useEffect(() => { if (link) { setFormData({ originalUrl: link.originalUrl || "", title: link.title || "", description: link.description || "", tags: link.tags ? link.tags.join(", ") : "", password: "", expiresAt: link.expiresAt ? new Date(link.expiresAt).toISOString().slice(0, 16) : "", }); setRemovePassword(false); } }, [link]); // ← re-syncs form when a different link is selected for editing

This is NOT data fetching — it's using useEffect to sync external data (props) into local state. When the parent passes a different link prop, this effect resets the form fields. This is the correct pattern when you need to "derive initial state from props" but also need the form to be editable (controlled).

[!NOTE] Alternative without useEffect: In simpler cases, you can use a key prop to force React to unmount and remount the component with fresh state: <EditLinkModal key={link.id} link={link} />. Each new key value creates a brand-new component instance with fresh useState calls. No useEffect needed.


📝 The Why React Query Exists (Connecting 3.3 to 5.5)

Every pitfall in this section — race conditions, no caching, no dedup, manual loading/error booleans — is boilerplate you write over and over in every component that fetches data.

// With React Query — all of the above problems are solved: import { useQuery } from '@tanstack/react-query'; function LinkAnalytics({ id }) { const { data, isLoading, error } = useQuery({ queryKey: ['analytics', id], // cache key — auto dedupes queryFn: () => api.get(`/api/v1/analytics/${id}`).then(r => r.data), staleTime: 30_000, // don't refetch for 30s }); if (isLoading) return <Spinner />; if (error) return <ErrorBanner />; return <Chart data={data} />; }

What React Query handles for you:

  • Race condition protection (stale queries are auto-cancelled)
  • Caching (navigating away and back doesn't re-fetch)
  • Request deduplication (two components requesting the same data = one HTTP call)
  • Background refetching (data stays fresh automatically)
  • Loading/error states (no manual boolean wiring)

[!TIP] Killer interview answer: "I'd distinguish client state (UI state like 'is modal open') from server state (data from the API). For client state, useState/useReducer/Context is fine. For server state, I'd use React Query because it solves caching, dedup, race conditions, and stale data — problems that useEffect + useState makes you solve manually every time."


3.4 Error Boundaries

The Problem They Solve

Without an error boundary, a single JavaScript error in a component's render path crashes the entire React tree — the user sees a blank white page.


📝 The Pattern (Still Class-Only)

class ErrorBoundary extends React.Component { state = { hasError: false, error: null }; // Called during rendering — returns new state static getDerivedStateFromError(error) { return { hasError: true, error }; } // Called after the error — use for logging componentDidCatch(error, errorInfo) { // Send to your logging service (Sentry, LogRocket, etc.) console.error('ErrorBoundary caught:', error, errorInfo.componentStack); } render() { if (this.state.hasError) { return ( <div style={{ padding: '2rem', textAlign: 'center' }}> <h2>Something went wrong</h2> <p>{this.state.error?.message}</p> <button onClick={() => this.setState({ hasError: false, error: null })}> Try Again </button> </div> ); } return this.props.children; } }

Usage — wrap at route level:

// In App.jsx — each route gets its own boundary <Route path="/analytics/:id" element={ <ErrorBoundary> <LinkAnalytics /> </ErrorBoundary> } />

Now if LinkAnalytics throws during render, only that page shows the fallback — the rest of the app (nav, sidebar) stays functional.


📝 Practical: Where to Place Error Boundaries

<App> <ErrorBoundary> ← catches everything (last resort) <BrowserRouter> <Navbar /> ← stays visible even if a page crashes <Routes> <Route path="/" element={ <ErrorBoundary> ← page-level: only crashes this route <Home /> </ErrorBoundary> } /> <Route path="/analytics/:id" element={ <ErrorBoundary> <LinkAnalytics> <ErrorBoundary> ← widget-level: only crashes the chart <TrafficChart /> </ErrorBoundary> </LinkAnalytics> </ErrorBoundary> } /> </Routes> </BrowserRouter> </ErrorBoundary> </App>

Strategy: Multiple nested boundaries at different granularities. A broken chart shouldn't crash the entire analytics page.


⚠️ What Error Boundaries DON'T Catch

This is the interview question for this topic:

❌ Not caught by Error Boundaries✅ What to use instead
Event handler errors (onClick throws)try/catch inside the handler
Async code (Promises, setTimeout)try/catch + .catch()
Server-side rendering (SSR) errorsSSR-specific error handling
Errors in the error boundary itselfA parent error boundary
// ❌ Error boundary will NOT catch this: const handleClick = () => { throw new Error('Boom'); // event handler — not during render }; // ✅ You need try/catch: const handleClick = () => { try { riskyOperation(); } catch (error) { toast.error(error.message); } };
// ❌ Error boundary will NOT catch this: useEffect(() => { setTimeout(() => { throw new Error('Delayed boom'); // async — not during render }, 1000); }, []); // ✅ Handle async errors explicitly: useEffect(() => { const fetchData = async () => { try { const res = await api.get('/some-endpoint'); setData(res.data); } catch (error) { setError(error.message); // update state to show error UI } }; fetchData(); }, []);

📝 Trick to Make Async Errors Catchable by Error Boundaries

function useThrowAsyncError() { const [, setState] = useState(); return (error) => { setState(() => { throw error; }); // Throwing inside a setState updater happens during rendering // → Error Boundary CAN catch it! }; } // Usage: function LinkAnalytics() { const throwError = useThrowAsyncError(); useEffect(() => { api.get('/analytics').catch(err => throwError(err)); // ↑ throws during next render // → caught by nearest ErrorBoundary }, []); }

This is an advanced pattern — throwing inside a state updater function causes the throw to happen during React's render phase, where error boundaries operate. Neat trick for interviews.


🔥 Full Section Interview Quick-Fire

QuestionAnswer
Controlled vs uncontrolled?Controlled = React state owns the value. Uncontrolled = DOM owns it, you read via ref.
Why is my input frozen?Missing onChange — React keeps forcing value back to unchanged state.
File inputs — controlled?No, browser blocks programmatic value setting for security.
useEffect cleanup — when does it run?Before the effect re-runs (on dependency change) AND on unmount.
Race condition in useEffect?Use a cancelled flag or AbortController in the cleanup function.
Why not fetch in useEffect for production?No caching, no dedup, race conditions, manual loading/error boilerplate → use React Query.
Error boundaries — hook version?Doesn't exist (as of React 19). Still class-only.
What don't error boundaries catch?Event handlers, async code, SSR, errors in the boundary itself.
Promise.all vs sequential awaits?Promise.all = parallel (faster). Sequential = one after another (slower but needed when call 2 depends on call 1).

🏋️ Practice Exercises

  1. Open CreateLink.jsx — change handleChange to use the functional updater form (prev => ...). Verify the form still works.

  2. Open LinkAnalytics.jsx — add a cancelled flag to guard against race conditions when range changes rapidly.

  3. Explain out loud (no looking): Why can't you make the useEffect callback itself async? (Because it must return undefined or a cleanup function — an async function always returns a Promise, which React can't use as a cleanup.)

  4. Write from scratch: A useFetch(url) custom hook that returns { data, loading, error } with race condition protection. This is a very common interview live-coding ask.