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,開始一個一個換掉。
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,全部在裡面了。
那個還沒寫完的 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 再細談。
整理完,腦中出現一條很清楚的分工:
useState,要跨元件共享就用 Zustand接著我打開 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 的世界裡,到底是誰在管。