useFetch 自定义 Hook面试官口述需求:
“请在不支持任何第三方库(不能用 React Query/SWR)的情况下,用原生 React 手写一个自定义 Hook,命名为
useFetch。它可以传入一个 URL,并返回{ data, loading, error }。”
我们现在就通过中英双语(Bilingual),像真实面试一样,一步步把这道题推导出来。
在面试中,当你不知道怎么做时,最棒的策略是 “Think Out Loud”(大声思考)。我们可以把这个问题拆成 3 个简单的步骤。
首先,我们要想:这个 Hook 需要返回什么?面试官说要返回 data, loading, 和 error。所以,我们需要在 Hook 内部用 useState 把这三个状态定义出来。
English Thought Process:
"First, I need to define three states using
useStateto track the API response:datafor the fetched result,loadingto indicate the request status, anderrorto catch any potential failures. Then, the hook will return an object containing these three states."
中文思考路径:
“第一步,我需要用
useState定义三个状态来追踪 API 的响应:data存返回的结果,loading表示加载状态,error捕获可能的错误。最后,这个 Hook 会返回一个包含这三个状态的对象。”
import { useState, useEffect } from 'react';function useFetch(url) { // Define our states / 定义我们的状态 const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Todo: Fetch data here / 稍后在这里发请求 // Return the object / 返回这个对象 return { data, loading, error };}
当 url 发生变化时,我们需要触发网络请求。在 React 中,处理网络请求这种“副作用”的标准做法是使用 useEffect,并且把 url 放在依赖项数组(Dependency Array)里。
English Thought Process:
"Next, I will use
useEffectto trigger the network fetch whenever theurlchanges. InsideuseEffect, I'll write anasyncfunction to perform the standardfetchcall, handle the response, and update our states accordingly."
中文思考路径:
“第二步,我使用
useEffect。只要url发生改变,就重新触发网络请求。在useEffect内部,我会写一个异步函数(async function)来执行标准的fetch请求,拿到数据后去更新我们刚刚定义的状态。”
import { useState, useEffect } from 'react';function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { // 1. Reset states when URL changes / 当 URL 改变时,重置状态 setLoading(true); setError(null); // 2. Create the fetch function / 创建发请求的异步函数 const fetchData = async () => { try { const response = await fetch(url); // Check if the HTTP status is OK (e.g., 200-299) // 检查 HTTP 状态码是否正常 if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); setData(json); // Update data / 更新数据 } catch (err) { setError(err.message); // Catch and set error / 捕获并设置错误 } finally { setLoading(false); // Turn off loading indicator / 关闭加载状态 } }; // 3. Execute the function / 执行函数 fetchData(); }, [url]); // Dependency is url / 依赖项是 url return { data, loading, error };}
上面的代码已经拿到了 70 分,但真实的欧美面试官绝对会立刻抛出我们之前提过的致命追问(The Race Condition & Cleanup Trap):
"What happens if the component unmounts while the fetch is still in progress? Or what if the URL changes rapidly before the first request finishes?" (如果请求还没完组件就被销毁了,或者用户疯狂切换 URL 导致前一个请求还没结束、后一个请求又发出了,会发生什么?)
答案是:会发生竞态条件(Race Condition)或者内存泄漏,旧请求返回的数据可能会覆盖新请求的数据!
为了解决这个问题,我们需要在 useEffect 中引入原生的 AbortController,并在 useEffect 的 cleanup function(清除函数) 中取消请求。
English Explanation to Interviewer:
"To handle race conditions and prevent setting state on an unmounted component, I will use native
AbortController. In the cleanup function ofuseEffect, I will callcontroller.abort(). If a new URL comes in, the previous pending request will be canceled immediately."
中文解释给面试官:
“为了处理竞态条件并防止在组件销毁后继续设置状态,我会使用浏览器原生的
AbortController。在useEffect的清除函数(cleanup function)中,我会调用controller.abort()。这样如果有一个新的 URL 传进来,前一个还没完成的请求就会被立刻取消。”
x
import { useState, useEffect } from 'react';function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { // 1. Instantiate the AbortController / 实例化取消控制器 const controller = new AbortController(); const signal = controller.signal; setLoading(true); setError(null); const fetchData = async () => { try { // 2. Pass the signal to the fetch request / 将 signal 传给 fetch const response = await fetch(url, { signal }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); setData(json); } catch (err) { // 3. Ignore the error if it was triggered by our manual abort // 如果错误是因为我们手动取消导致的,就不必把它当成真实错误抛给用户 if (err.name !== 'AbortError') { setError(err.message); } } finally { // 4. Only turn off loading if the request wasn't aborted // 只有在请求没有被取消的情况下,才关闭加载状态 if (!signal.aborted) { setLoading(false); } } }; fetchData(); // 5. The Magic Cleanup Function / 最核心的清除函数 return () => { controller.abort(); // Cancel the fetch / 取消该请求 }; }, [url]); return { data, loading, error };}
看,在没有 AI 的情况下,顺着 “定义状态 -> 处理副作用 -> 优化边界条件” 的逻辑,配上原生的 AbortController,你就能完美写出一个工业级的自定义 Hook。这也是欧美高薪岗位最想看到的扎实基本功。
You have exactly the right mindset! In a real interview for a $5000/mo remote role, writing the initial code is only 30% of the battle. The remaining 70% is how you defend and adapt your code when the interviewer starts grillin' you with follow-up questions.
Let's stay on Question 1 (useFetch). You just submitted that beautiful version with AbortController. I am now stepping back into my role as your Senior Remote Interviewer.
Interviewer:
"Your hook works perfectly fine, but I noticed something about your return statement. Every time
useFetchre-runs, it returns a brand new object literal:return { data, loading, error };.""If a consumer component destructures this object inside a
useEffectdependency array like this:useEffect(() => { ... }, [fetchResult]), it will trigger an infinite rendering loop because the object reference changes on every single render. How would you solve this object reference stability issue without forcing the user to memoize it on their end?"
中文翻译提示:
“你的 Hook 跑得很好。但我注意到你的返回值:每次
useFetch重新渲染,它都会返回一个全新的对象字面量return { data, loading, error };。”“如果用户在组件里把这个返回对象放进了另一个
useEffect的依赖项里,因为每次引用地址都不同,就会导致死循环渲染。你该怎么在你的 Hook 内部解决这个返回值引用不稳定的问题,而不用让用户自己去特殊处理?”
Interviewer:
"Right now, your
useFetchhook is purely local. If I have two different components on the screen rendering at the same time, and both of them calluseFetch('/api/user'), it will fire two separate HTTP requests to the backend backend. Furthermore, if I navigate away and come back, it refetches and shows the loading spinner again.""Without installing React Query or Zustand, how can you add a simple In-Memory Cache Mechanism inside this file so that if a URL has already been fetched successfully, subsequent components can read the data instantly from memory instead of hitting the network?"
中文翻译提示:
“目前你的
useFetch是纯组件局部的。如果屏幕上同时渲染了两个不同的组件,它们都调用了useFetch('/api/user'),这会向后端发出两次独立的 HTTP 请求。而且如果我切走页面再切回来,它又会重新请求并显示 loading。”“在不安装 React Query 或 Zustand 的前提下,你如何在当前文件里加一个简单的内存缓存机制?这样只要某个 URL 曾经请求成功过,后续的组件就能立刻从内存里读数据,而不是去走网络请求?”
Don't panic! You don't have to write perfect code right away. Try to answer one or both of these follow-up questions using the "Think Out Loud" approach we practiced.
How would you tackle these two scenarios? Reply with your ideas or code snippets (in Chinese or English), and let's see how we can level up your code to a Staff Engineer standard!
No worries at all! This is exactly why we practice. These two follow-up questions are precisely how interviewers differentiate a Junior developer from a Senior/Lead developer Interviewing.io Feedback.
Let’s break them down one by one using our Bilingual (中英双语) approach and see how simple the solutions actually are.
Every time our hook runs, return { data, loading, error }; creates a brand new object in JavaScript memory. Even if data, loading, and error haven't changed, the container object has a new memory address. This breaks React's shallow comparison and can cause infinite rendering loops Interviewing.io Tips.
We can use React's useMemo to cache the returned object. It will only create a new object reference when data, loading, or error actually change.
English Thought Process & Explanation:
"To prevent unnecessary re-renders and stabilize the object reference, I will wrap the returned object inside a
useMemo. This ensures that the consumer component receives the exact same object reference across renders, unless the inner state (data,loading, orerror) changes."
中文思考与解释:
“为了防止不必要的二次渲染并稳定对象的内存引用,我会使用
useMemo把要返回的对象包裹起来。这样能确保只要内部状态(data、loading或error)没有改变,消费该 Hook 的组件在每次渲染时拿到的都是同一个对象引用。”
xxxxxxxxxximport { useState, useEffect, useMemo } from 'react';function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { /* ... existing fetch logic with AbortController ... */ }, [url]); // 🌟 FIX: Memoize the output object / 缓存输出对象 return useMemo(() => ({ data, loading, error }), [data, loading, error]);}
Currently, our hook holds its state inside the function component. When the component unmounts, the data is wiped out. If multiple components call the hook, they don't share data.
We can declare a simple JavaScript Object (const cache = {}) outside the useFetch function block (at the file/module level). Because it's outside the hook, it acts as a global in-memory singleton cache that persists across all components and re-renders!
English Thought Process & Explanation:
"To implement an in-memory cache without external libraries, I can declare a simple JavaScript object named
cacheoutside the custom hook function. Before firing any network request, I will check if theurlexists in thiscacheobject. If it does, I'll instantly set the data from memory and skip thefetchcall."
中文思考与解释:
“要在不依赖第三方库的前提下实现内存缓存,我可以在自定义 Hook 函数的外部声明一个普通的 JavaScript 对象
cache。在每次发起网络请求之前,先检查这个cache对象里是否已经有了对应的url。如果有,就直接从内存中读取数据并更新状态,从而跳过实际的网络请求。”
xxxxxxxxxximport { useState, useEffect, useMemo } from 'react';// 🌟 1. Module-level global cache / 全局模块级别的缓存对象const cache = {};function useFetch(url) { // If we already have the data in cache, we can initialize data with it // 如果缓存里已经有数据了,我们可以直接用它来作为 data 的初始值 const [data, setData] = useState(cache[url] || null); const [loading, setLoading] = useState(!cache[url]); // No loading if cached / 有缓存就不需要 loading const [error, setError] = useState(null); useEffect(() => { // 🌟 2. Guard Clause: If data is already cached, do nothing // 守护条件:如果数据已经在缓存中了,直接返回,不再发网络请求 if (cache[url]) { setData(cache[url]); setLoading(false); return; } const controller = new AbortController(); const signal = controller.signal; setLoading(true); setError(null); const fetchData = async () => { try { const response = await fetch(url, { signal }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); // 🌟 3. Store the result in our global cache / 将结果存入全局缓存 cache[url] = json; setData(json); } catch (err) { if (err.name !== 'AbortError') setError(err.message); } finally { if (!signal.aborted) setLoading(false); } }; fetchData(); return () => { controller.abort(); }; }, [url]); return useMemo(() => ({ data, loading, error }), [data, loading, error]);}
Look at what you just achieved! By adding just a few lines of code without AI:
AbortController.useMemo.This is exactly what senior interviewers at top-tier startups look for. They don't care if you know every Next.js API by heart, but they care immensely about whether you understand how JavaScript memory, network requests, and React rendering interact.
Would you like us to move on to Question 2 (The Real-Time Search Box with Debounce and URL sync), or do you have any quick questions about this caching mechanism? Let's keep the momentum going!
Yes, absolutely! An interviewer for a high-paying remote role ($5000+/mo) can push you even further into Staff/Principal Engineer territory.
Now that you have built a custom hook with AbortController, useMemo, and an In-Memory Cache, a top-tier interviewer will start challenging the architecture and production scaling of your solution.
Here are the next 3 brutal follow-up questions they would ask you on this exact same problem, along with how to handle them in 中英双语 (Bilingual).
Interviewer:
"Your module-level
cacheobject works, but it lasts forever until the user refreshes the browser. If the server data changes frequently, the user will be stuck looking at stale data forever. Also, if the user visits 1,000 different URLs, yourcacheobject will grow indefinitely, causing a memory leak.""How would you add a simple Time-To-Live (TTL) or expiration mechanism so that the cached data automatically invalidates after, say, 5 minutes?"
中文翻译提示:
“你的全局
cache对象确实管用,但除非用户刷新浏览器,否则这些数据永远不会消失。如果后端数据频繁更新,用户看到的就永远是旧数据。而且,如果用户访问了 1000 个不同的网址,你的cache对象会无限膨胀,导致内存泄漏。”“你该怎么加一个简单的 TTL(生存时间) 缓存失效机制,让数据在存入 5 分钟后自动过期并强制重新请求?”
Instead of just storing the raw data (cache[url] = json), we should store an object containing both the data and a timestamp of when it was fetched (cache[url] = { data: json, timestamp: Date.now() }). Before reading from the cache, we check if the current time minus the timestamp exceeds 5 minutes.
xxxxxxxxxx// 🌟 Change cache structure to include timestamps// 将缓存结构改变,带上时间戳const cache = {}; const TTL = 5 * 60 * 1000; // 5 minutes in milliseconds// Inside useEffect when reading cache:const cachedItem = cache[url];if (cachedItem && (Date.now() - cachedItem.timestamp < TTL)) { setData(cachedItem.data); setLoading(false); return;}// Inside fetch success logic when writing cache:cache[url] = { data: json, timestamp: Date.now() // Save current time / 记录当前时间};
Interviewer:
"Imagine a dashboard page where Component A and Component B are mounted at the exact same millisecond. Both of them call
useFetch('/api/profile')simultaneously.""Since the first request hasn't finished yet,
cache['/api/profile']is still empty. Therefore, BOTH components will bypass the cache check and fire two parallel HTTP requests for the exact same data. How do you implement Request Deduplication so that if a request for a URL is already in-flight, the second component waits for the first request instead of firing a new one?"
中文翻译提示:
“想象一个仪表盘页面,组件 A 和 组件 B 在同一毫秒被渲染。它们同时调用了
useFetch('/api/profile')。”“因为第一个请求此时还没结束,
cache里还是空的。所以这两个组件都会通过缓存检查,向后端发送两个并发的 HTTP 请求。你该如何实现请求去重?确保如果某个 URL 的请求已经在路上了,第二个组件会等待它的结果,而不是重复发请求?”
Instead of caching the final data, we can cache the active Promise itself! If a component sees that there is a pending Promise for that URL in the cache, it just chains onto that existing Promise (await pendingPromise) instead of calling fetch() again.
xxxxxxxxxx// 🌟 Store active promises in a separate tracker// 在一个单独的追踪器里存正在进行中的 Promiseconst inflightRequests = {}; // Inside useEffect:if (!cache[url]) { if (!inflightRequests[url]) { // Fire the actual fetch and store the PROMISE / 触发真实的 fetch 并存入 Promise inflightRequests[url] = fetch(url).then(res => res.json()); } try { // Both components await the SAME promise / 两个组件等待同一个 Promise const json = await inflightRequests[url]; cache[url] = json; setData(json); } catch (err) { setError(err.message); } finally { delete inflightRequests[url]; // Clean up / 请求结束清除追踪 }}
Interviewer:
"Since you are applying for a Next.js Full-Stack role, remember that custom hooks run on the server during SSR before hitting the browser. Your code references browser-specific variables like
AbortControllerandfetch(if on older Node environments), and it modifies a globalcacheobject.""If 100 different users access this Next.js site simultaneously, they will all share the exact same
const cache = {}on your Node.js server, leading to severe cross-user data leaks (User A seeing User B's private data). How do you make this hook safe for Next.js SSR?"
中文翻译提示:
“既然你申请的是 Next.js 全栈岗位,别忘了自定义 Hook 在服务端渲染(SSR)阶段也会在服务器上执行。你的代码引用了浏览器专有的
AbortController,而且修改了全局cache对象。”“如果 100 个不同的用户同时访问这个 Next.js 网站,他们在你的 Node.js 服务器上会共享同一个
cache对象,导致严重的用户数据泄露(用户 A 看到了用户 B 的隐私数据)。你怎么让这个 Hook 在 Next.js SSR 环境下变得安全?”
To fix cross-user leaks in SSR, global server caching must be avoided. Caching should only happen on the client side (inside the browser). We can wrap the caching and fetching logic to ensure it only executes when typeof window !== 'undefined', or enforce that the cache is only written during client-side hydration. Alternatively, we tell the interviewer that for Next.js, we would leverage Next.js’s native server-side fetch() cache or unstable_cache for server-level caching, and keep the hook strictly for client-side interactions by turning off SSR for this component or checking the environment.
As you can see, a simple 10-line useFetch hook can explode into a deep architectural debate about memory, networks, concurrency, and backend server environments. This is why you don't need to memorize 100 interview questions. You just need to know how to deeply dissect a few.
Wow, you have an incredible appetite for depth! This is exactly what separates great engineers from average ones.
To answer your question: Yes, an elite principal or staff-level interviewer can push this single useFetch hook to its absolute architectural limit.
At this stage, they are no longer testing if you know React or Next.js. They are testing if you understand Browsers, Operating Systems, Memory Management, and Network Protocols.
Here are the final 2 "boss-level" follow-up questions an interviewer could possibly ask on this topic, explained in 中英双语 (Bilingual):
Interviewer:
"Let's look at your global
cacheobject again. Suppose this is a large enterprise application. Over a 5-hour user session, the user navigates through thousands of pages, loading megabytes of heavy JSON data into yourcacheobject.""Even if you implement a TTL (expiration time), the JavaScript Garbage Collector (GC) cannot clear those expired objects from memory because your global
cacheobject still holds a strong reference to their keys and values. How do you prevent this from causing a browser tab crash due to out-of-memory, without manually writing a complex cache-clearing loop?"
中文翻译提示:
“再来看看你的全局
cache对象。假设这是一个大型企业级应用。在用户长达 5 小时的使用过程中,用户浏览了数千个页面,将数 MB 的沉重 JSON 数据加载到了你的cache对象中。”“哪怕你实现了 TTL(过期时间),JavaScript 的垃圾回收机制(GC)也无法从内存中清理这些过期对象,因为你的全局
cache对象仍然牢牢持有它们的引用。你怎么防止这导致浏览器标签页因内存溢出而崩溃,同时又不用自己手动写复杂的定时清理循环?”
Instead of a plain JavaScript object ({}), we should use a native FinalizationRegistry combined with standard map tracking, or a customized LRU (Least Recently Used) Cache algorithm that limits the cache to a maximum of, say, 100 items. When item 101 comes in, the oldest item is automatically deleted, keeping memory usage perfectly flat.
Interviewer:
"Since we are coding in the modern React era (React 19 / Next.js App Router), the React team is actively moving away from triggering data fetching inside
useEffectbecause of all the boilerplate we just wrote (cleaning up, caching, handling race conditions).""React 19 introduced the new
usehook which allows you to consume Promises directly in the render phase. If I ask you to completely deleteuseEffectfrom this file, how would you refactor this hook to leverage React 19's native Suspense architecture and theuse(promise)API?"
中文翻译提示:
“既然我们处于现代 React 时代(React 19 / Next.js App Router),React 团队正在极力让大家不要在
useEffect里面写数据请求,因为这需要写太多死板的模板代码(清理、缓存、处理竞态条件)。”“React 19 引入了全新的
useHook,允许你在渲染阶段直接消费 Promise。如果我要求你把这个文件里的useEffect完全删掉,你该如何重构这个 Hook,从而利用 React 19 原生的 Suspense 架构和use(promise)API 呢?”
In React 19, instead of tracking state (data, loading, error) locally, we cache the Promise itself at the module level. Then, inside the hook, we simply return use(cachedPromise). React's <Suspense> boundary outside the component will automatically handle the loading state, and the <ErrorBoundary> will automatically catch the error. The hook shrinks from 50 lines down to 5 lines!
xxxxxxxxxx// React 19 Style (No useEffect, No useState)import { use } from 'react'; // React 19 native APIconst promiseCache = {};function useFetch(url) { if (!promiseCache[url]) { // Cache the promise, not the data / 缓存 Promise 本身,而不是数据 promiseCache[url] = fetch(url).then(res => { if (!res.ok) throw new Error('Network error'); return res.json(); }); } // React 19 unwraps the promise automatically. // It triggers Suspense while loading, and ErrorBoundary on failure. // React 19 会自动解开 Promise。加载时自动触发 Suspense,失败时自动触发 ErrorBoundary。 return use(promiseCache[url]); }
We have now officially exhausted this question. You just saw how a single coding prompt can scale from a junior-level implementation up to a staff-level architectural deep-dive into browser memory and React 19 core internals.
This is the secret of international remote interviews: They don't expect you to know everything, but they want to see how far your technical curiosity goes when pushed into a corner.
You did an amazing job holding your ground and walking through this with me.
We can now officially lock Question 1 away as "Mastered."