题目一演练:手写 useFetch 自定义 Hook

题目要求

面试官口述需求:

“请在不支持任何第三方库(不能用 React Query/SWR)的情况下,用原生 React 手写一个自定义 Hook,命名为 useFetch。它可以传入一个 URL,并返回 { data, loading, error }。”

 

我们现在就通过中英双语(Bilingual),像真实面试一样,一步步把这道题推导出来。

在面试中,当你不知道怎么做时,最棒的策略是 “Think Out Loud”(大声思考)。我们可以把这个问题拆成 3 个简单的步骤。

 

Step 1: Define the State (定义状态)

首先,我们要想:这个 Hook 需要返回什么?面试官说要返回 data, loading, 和 error。所以,我们需要在 Hook 内部用 useState 把这三个状态定义出来。

English Thought Process:

"First, I need to define three states using useState to track the API response: data for the fetched result, loading to indicate the request status, and error to catch any potential failures. Then, the hook will return an object containing these three states."

中文思考路径:

“第一步,我需要用 useState 定义三个状态来追踪 API 的响应:data 存返回的结果,loading 表示加载状态,error 捕获可能的错误。最后,这个 Hook 会返回一个包含这三个状态的对象。”

 

Step 2: Trigger the Side Effect (触发副作用发请求)

url 发生变化时,我们需要触发网络请求。在 React 中,处理网络请求这种“副作用”的标准做法是使用 useEffect,并且把 url 放在依赖项数组(Dependency Array)里。

English Thought Process:

"Next, I will use useEffect to trigger the network fetch whenever the url changes. Inside useEffect, I'll write an async function to perform the standard fetch call, handle the response, and update our states accordingly."

中文思考路径:

“第二步,我使用 useEffect。只要 url 发生改变,就重新触发网络请求。在 useEffect 内部,我会写一个异步函数(async function)来执行标准的 fetch 请求,拿到数据后去更新我们刚刚定义的状态。”

 

Step 3: Handle the Interviewer's Trap (应对面试官的加分追问)

上面的代码已经拿到了 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,并在 useEffectcleanup 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 of useEffect, I will call controller.abort(). If a new URL comes in, the previous pending request will be canceled immediately."

中文解释给面试官:

“为了处理竞态条件并防止在组件销毁后继续设置状态,我会使用浏览器原生的 AbortController。在 useEffect 的清除函数(cleanup function)中,我会调用 controller.abort()。这样如果有一个新的 URL 传进来,前一个还没完成的请求就会被立刻取消。”

 

最终满分全英文/全手写代码:

 

恭喜你,第一题通关!

看,在没有 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.

 

Follow-Up Question 1: Object Reference Issue (Object Re-rendering)

Interviewer:

"Your hook works perfectly fine, but I noticed something about your return statement. Every time useFetch re-runs, it returns a brand new object literal: return { data, loading, error };."

"If a consumer component destructures this object inside a useEffect dependency 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 内部解决这个返回值引用不稳定的问题,而不用让用户自己去特殊处理?”

 

Follow-Up Question 2: Caching & Global State

Interviewer:

"Right now, your useFetch hook is purely local. If I have two different components on the screen rendering at the same time, and both of them call useFetch('/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 曾经请求成功过,后续的组件就能立刻从内存里读数据,而不是去走网络请求?”

 

Now, it's your turn to defend your code!

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.

 

Follow-Up 1: Fixing the Object Reference Issue (解决返回值引用问题)

The Problem (问题所在):

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.

The Solution (解决方案):

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, or error) changes."

中文思考与解释:

“为了防止不必要的二次渲染并稳定对象的内存引用,我会使用 useMemo 把要返回的对象包裹起来。这样能确保只要内部状态(dataloadingerror)没有改变,消费该 Hook 的组件在每次渲染时拿到的都是同一个对象引用。”

Code Adjustment (代码微调):

 

Follow-Up 2: Adding an In-Memory Cache (添加内存缓存机制)

The Problem (问题所在):

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.

The Solution (解决方案):

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 cache outside the custom hook function. Before firing any network request, I will check if the url exists in this cache object. If it does, I'll instantly set the data from memory and skip the fetch call."

中文思考与解释:

“要在不依赖第三方库的前提下实现内存缓存,我可以在自定义 Hook 函数的外部声明一个普通的 JavaScript 对象 cache。在每次发起网络请求之前,先检查这个 cache 对象里是否已经有了对应的 url。如果有,就直接从内存中读取数据并更新状态,从而跳过实际的网络请求。”

Code Adjustment (代码微调):

 

Summary for Your Interview Arsenal (面试通关总结)

Look at what you just achieved! By adding just a few lines of code without AI:

  1. You solved Race Conditions using AbortController.
  2. You fixed Performance/Infinite Loop issues using useMemo.
  3. You built a Global Cache Mechanism using a basic JavaScript closure module pattern.

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).

 

Advanced Follow-Up 1: Cache Invalidation & Expiration (缓存过期与失效)

Interviewer:

"Your module-level cache object 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, your cache object 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 分钟后自动过期并强制重新请求?”

The Solution / 解决方案:

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.

 

Advanced Follow-Up 2: Request Deduplication / Network Race (请求去重/并发控制)

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 的请求已经在路上了,第二个组件会等待它的结果,而不是重复发请求?”

The Solution / 解决方案:

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.

 

Advanced Follow-Up 3: Next.js Server-Side Rendering (SSR) Safety (服务端渲染安全性)

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 AbortController and fetch (if on older Node environments), and it modifies a global cache object."

"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 环境下变得安全?”

The Solution / 解决方案:

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.

🚀 What this means for you:

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):

Boss-Level Follow-Up 1: The "10,000 Components" Memory Pressure (内存暴涨与垃圾回收)

Interviewer:

"Let's look at your global cache object 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 your cache object."

"Even if you implement a TTL (expiration time), the JavaScript Garbage Collector (GC) cannot clear those expired objects from memory because your global cache object 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 对象仍然牢牢持有它们的引用。你怎么防止这导致浏览器标签页因内存溢出而崩溃,同时又不用自己手动写复杂的定时清理循环?”

The Solution / 解决方案:

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.

 

Boss-Level Follow-Up 2: React 19 Architecture Alignment (React 19 异步流控制)

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 useEffect because of all the boilerplate we just wrote (cleaning up, caching, handling race conditions)."

"React 19 introduced the new use hook which allows you to consume Promises directly in the render phase. If I ask you to completely delete useEffect from this file, how would you refactor this hook to leverage React 19's native Suspense architecture and the use(promise) API?"

中文翻译提示:

“既然我们处于现代 React 时代(React 19 / Next.js App Router),React 团队正在极力让大家不要useEffect 里面写数据请求,因为这需要写太多死板的模板代码(清理、缓存、处理竞态条件)。”

“React 19 引入了全新的 use Hook,允许你在渲染阶段直接消费 Promise。如果我要求你把这个文件里的 useEffect 完全删掉,你该如何重构这个 Hook,从而利用 React 19 原生的 Suspense 架构和 use(promise) API 呢?”

The Solution / 解决方案:

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!

 

The Ultimate Takeaway from Question 1

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."