iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 14 篇

Day 14|以為手刻的東西,一個套件五分鐘解決

  • 分享至 

  • xImage
  •  

星期一早上,打開了 lib/ 資料夾

週末想了兩天,決定面對現實。

打開專案,把這一週手刻的東西全部列出來:

lib/
  useSearch.ts          ← Day 9,競態 + AbortController,快三十行
  useDebouncedValue.ts  ← Day 11,十行
  quotaStore.ts         ← Day 13,拿 Zustand 硬存 API 資料,還沒寫完
features/dashboard/
  loadDashboard.ts      ← Day 10,Promise.all + 失敗時全部取消
features/files/
  upload.ts             ← 上傳完要記得手動重抓列表跟空間用量

每一個都能動,每一個我都知道它為什麼長這樣。

然後我裝了 TanStack Query,開始一個一個換掉。


一、先接上 QueryClient

TanStack Query 需要一個 QueryClient。在 React Router 的根元件接上:

// app/root.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

export default function App() {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      <Outlet />
    </QueryClientProvider>
  );
}

注意那個 useState(() => new QueryClient())。

我第一反應是寫成模組層級的 export const queryClient = new QueryClient(),就像 Day 2 的 apiClient。但官方文件特別提醒:有 SSR 的環境,這樣寫會讓所有使用者共用同一個快取。

模組單例在伺服器上是整個 process 共用的。A 使用者的檔案清單,可能出現在 B 使用者的畫面上。放進 useState 裡,就變成每一次渲染樹各有一份。


二、一個一個換掉

useSearch:三十行變六行

// 之前:Day 9
function useSearch(keyword: string) {
  const [results, setResults] = useState<Result[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    // ...then、catch、濾掉 AbortError、setLoading(false)……
    return () => controller.abort();
  }, [keyword]);

  return { results, loading, error };
}
// 之後
function useSearch(keyword: string) {
  return useQuery({
    queryKey: ['files', 'search', keyword],
    queryFn: ({ signal }) => api.search(keyword, { signal }),
    enabled: keyword.trim() !== '',
  });
}

競態、取消、loading、error,全部在裡面了。

空間用量:一個 hook 就是一個 service

那個還沒寫完的 Zustand store,直接刪掉:

export function useQuota() {
  return useQuery({
    queryKey: ['quota'],
    queryFn: ({ signal }) => api.getQuota({ signal }),
  });
}

頁首呼叫 useQuota(),儀表板也呼叫 useQuota()。打開 Network 面板:只有一個請求。

這不就是 Angular 裡那個 QuotaService?

// Angular
readonly quota$ = this.http.get('/api/quota').pipe(shareReplay(1));

custom hook 加上一個固定的 queryKey,就是我的 service。

上傳之後:不用再記得誰要更新

const queryClient = useQueryClient();

const upload = useMutation({
  mutationFn: api.uploadFile,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['files'] });
    queryClient.invalidateQueries({ queryKey: ['quota'] });
  },
});

以前上傳完,我要自己記得:列表要重抓、頁首的空間用量要重抓、右側面板如果開著也要重抓。現在只要宣告「這些資料不可信了」,誰在用誰就會自己更新。

儀表板:連 Promise.all 都不用寫

Day 10 那個 Promise.all 加 AbortController 的版本,換成這樣:

function Dashboard() {
  const files = useQuery({ queryKey: ['files'], queryFn: ... });
  const quota = useQuota();
  const recent = useQuery({ queryKey: ['files', 'recent'], queryFn: ... });
  // ...
}

三個 useQuery 寫在同一個元件裡,它們本來就會同時出發,不會排隊。而且每一個各自有自己的 loading 跟 error——「最近開啟」掛了,檔案清單照樣顯示。Day 10 用 allSettled 處理的事,這裡什麼都不用做。

算一下帳

功能 手刻 TanStack Query
搜尋(競態、取消、狀態) 約 30 行 約 6 行
空間用量(共用、去重) 約 25 行,還沒寫完 約 5 行
儀表板(平行、個別失敗) 約 20 行 3 個 useQuery
上傳後更新 散在好幾個地方 4 行
自動重試 沒做 預設就有
切回分頁重抓 沒做 預設就有
快取過期與清理 沒做 預設就有

我花三天寫的東西,換掉只用了一個早上。 而且換完之後,功能比原本還多。


三、留下來的東西

不是所有東西都被換掉了。整理完,lib/ 裡還剩這些:

useDebouncedValue,留著。 TanStack Query 管的是「資料怎麼存、什麼時候重抓」,不管「使用者打字多快」。十行,繼續用。

Zustand 的選取狀態,留著。 選了哪些檔案、篩選條件、側欄開關——這些是客戶端狀態,是我自己的東西,不是別人資料的副本,不需要快取。

React Router 的 loader,留著。 進入頁面就要有的資料,還是跟著路由走比較自然。兩者怎麼搭配,Week 4 再細談。

整理完,腦中出現一條很清楚的分工:

  • 伺服器狀態 → TanStack Query
  • 客戶端狀態 → useState,要跨元件共享就用 Zustand
  • 時間控制(debounce、throttle)→ 自己寫的小 hook

四、那三天白費了嗎?

接著我打開 TanStack Query 的設定文件,發現自己每一個選項都看得懂:

  • staleTime:資料多久內算新鮮、可以直接用?——Day 13 我在清單上寫的「存多久還能直接用」。
  • gcTime:用不到的資料多久之後清掉?——清單上的「不清的話 store 會越來越大」。
  • retry:失敗要重試幾次?——Day 9 盤點時寫的「沒有重試」。
  • signal:為什麼要往下傳?——Day 9 整篇都在講。

staleTime 預設是 0,意思是資料一拿到就算「可能過期」,每次有人重新用它,就在背景悄悄再確認一次。空間用量這種不會秒變的資料,我把它調成 30 秒。如果沒有手刻過那一週,我大概只會照抄範例,根本不知道這個數字在控制什麼。

手刻不是為了最後拿來用,而是為了讓我知道,這個套件替我擋掉了什麼。

跟我第一次發現 switchMap 在幫我擋競態的感覺,其實是一樣的。


五、抗拒期的總結

回頭看這一週,我原本以為自己在做一件事:把 RxJS 的運算子一個一個搬到 React。

RxJS 到了 React
Observable(lazy) Promise(eager),觀念要整個換掉
switchMap 自己刻 AbortController → 最後交給 TanStack Query
forkJoin Promise.all,或乾脆寫三個 useQuery
combineLatest 不需要,每次 render 都是快照
debounceTime 自己寫 useDebouncedValue,留下來了
distinctUntilChanged 免費,Object.is 已經在做
BehaviorSubject in service Zustand
shareReplay + refresh$ TanStack Query

排在一起,我才看見這一週真正的發現:

在 Angular,RxJS 一個人同時做了三份工作。

它控制時間(debounceTime、switchMap)、它共享客戶端狀態(BehaviorSubject)、它快取伺服器資料(shareReplay)。因為三份工作都用同一套工具在做,我從來沒意識到它們是三件不同的事。

React 把這三件事拆開了。時間控制是一個小 hook,客戶端狀態是 Zustand,伺服器狀態是 TanStack Query。每一件事,一個專門的工具。

我這週的抗拒,說穿了就是拿著一把瑞士刀,一直在找 React 裡的那把瑞士刀。而 React 那邊,一開始就沒打算做瑞士刀,它給你的是一整個工具箱,每把工具只做一件事。


今天的結論

說是投降也可以,但感覺不太像輸。比較像是終於不再用 Angular 的形狀去看 React,開始看見它自己的形狀。

Angular 是一個把電池都裝好的框架:DI、RxJS、HttpClient、表單、路由,全部內建,而且彼此配合。React 是一個很小的核心,外面圍著一圈各自獨立的函式庫,組合的責任在你身上。

前者的代價,是你不太會意識到每個零件在做什麼;後者的代價,是你得自己決定要裝哪些零件、怎麼接。

這一週我付的是後者的學費。好處是,現在我知道每個零件在做什麼了。

下週進入第三階段:認命重建。不再試圖把 Angular 搬過來,而是開始用 React 的方式,自己把規矩立起來。

第一件事,從最底層開始:ng build 一條龍幫我做完的那些事——建置、環境變數、開發伺服器、打包設定——在 Vite 的世界裡,到底是誰在管。


上一篇
Day 13|一直在手刻的,其實是一個快取
下一篇
Day 15|我的環境變數是 undefined
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言