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?
| Controlled | Uncontrolled | |
|---|---|---|
| Source of truth | React state (useState) | The DOM element itself |
| How you read the value | From the state variable | Via 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:
- User presses a key → browser fires
onChange setEmail(e.target.value)schedules a re-render- React re-renders, passes the new state value back to
value={email} - 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 forcingvalueback 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:
defaultValueinstead ofvalue(lets the DOM manage the value)refinstead ofonChange+ 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
valueprogrammatically (imagine a malicious site auto-selectingpasswords.txtfrom your desktop). You can only read the selected file viarefore.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
onChangehandler. React keeps forcingvalueback to the unchanged state. WithoutonChangecallingsetState, 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 havingvalue={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:
touchedstate: prevents showing errors before the user has interacted with a fieldonBlurvalidation: 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
touchedstate per field. Only show errors for fields the user has interacted with (viaonBluror 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/touchedstate), 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 mountWhat's going on here:
useEffect(..., [])— the empty dependency array means "run this once after the first render" (likecomponentDidMount)- We can't make
useEffect's callback itselfasync(it must returnundefinedor a cleanup function, not a Promise), so we define anasyncinner function and call it immediately Promise.allfires both requests in parallel — faster than sequential awaitsfinallyensures loading state clears even if the request fails
[!NOTE] Why
Promise.allinstead of two separateawaits? If each request takes 200ms, sequential awaits take ~400ms total.Promise.allfires 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 changesThe dependency array [id, range] means:
- When the user navigates to a different link (
idchanges) → re-fetch - When the user picks a different time range (
rangechanges) → 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:
- User selects "7d" → effect runs,
cancelled = false, request fires - User quickly selects "30d" → React runs cleanup from step 1 (
cancelled = true), then runs the new effect with its owncancelled = false - When the "7d" response finally arrives,
cancelledistruein its closure →setStateis skipped - The "30d" response arrives, its
cancelledis stillfalse→ 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:
cancelledflag: the request still completes, you just ignore the response → wastes bandwidthAbortController: 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-hooksrule (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 editingThis 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
keyprop to force React to unmount and remount the component with fresh state:<EditLinkModal key={link.id} link={link} />. Each newkeyvalue creates a brand-new component instance with freshuseStatecalls. NouseEffectneeded.
📝 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 thatuseEffect+useStatemakes 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) errors | SSR-specific error handling |
| Errors in the error boundary itself | A 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
| Question | Answer |
|---|---|
| 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
-
Open CreateLink.jsx — change
handleChangeto use the functional updater form (prev => ...). Verify the form still works. -
Open LinkAnalytics.jsx — add a
cancelledflag to guard against race conditions whenrangechanges rapidly. -
Explain out loud (no looking): Why can't you make the
useEffectcallback itselfasync? (Because it must returnundefinedor a cleanup function — an async function always returns a Promise, which React can't use as a cleanup.) -
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.