React River 完整教學
@zerologix/react-river— 一套受 Flutter Riverpod 啟發、輕量且高效能的 React 狀態管理函式庫。這份文件是「從零到熟練」的教學。想快速產生程式碼樣板,請看同目錄的
SKILL.md;想真正搞懂每個 API 為什麼這樣設計、什麼時候用哪個,請讀這一份。
目錄
- 先建立心智模型
- 安裝與最小可跑範例
- 核心觀念:Provider 是「定義」不是「狀態」
- 六種 Provider 逐一講解
- AsyncValue:優雅處理 loading / data / error
- 四個 React Hooks
- 呼叫 Notifier 的方法:
.notifier存取器 - Provider 之間的依賴:
ref.watchvsref.read - Family:帶參數的 Provider
- 生命週期與快取
- Scope 與 Overrides(隔離與測試)
- SSR 伺服器端渲染
- DevTools 與觀察者
- Consumer:不用 Hook 的情境
- 完整實戰範例
- 常見錯誤與最佳實務
- API 速查表
1. 先建立心智模型
在寫任何程式碼之前,先記住 React River 裡只有 三個角色:
| 角色 | 是什麼 | 在哪裡 |
|---|---|---|
| Provider(提供者) | 狀態的「定義/食譜」。它本身不存資料,只描述「這塊狀態怎麼算出來」。 | 你在檔案裡 export const xxxProvider = ... |
| Container(容器) | 真正存放狀態、追蹤依賴、負責快取與回收的引擎。 | 藏在 <RiverScope> 裡,你通常不用直接碰它 |
| Hooks(掛鉤) | React 元件用來「讀取/訂閱/操作」狀態的橋樑。 | useRiverWatch / useRiverRef / useRiverListen / useRiverMutation |
一句話比喻:
Provider 是食譜,Container 是廚房,Hooks 是點餐的服務生。 你寫食譜(Provider),廚房(Container)負責煮好並保溫(快取),元件透過服務生(Hooks)點餐、催單、退貨。
這個模型能解釋 React River 大部分的「為什麼」:
- 為什麼 Provider 可以放在元件外面、甚至跨檔案共用?因為它只是「定義」,沒有狀態綁在某個元件上。
- 為什麼同一個 Provider 在不同
<RiverScope>底下可以有各自的值?因為狀態存在 Container,而 Scope 決定用哪個 Container。 - 為什麼元件會自動重繪?因為
useRiverWatch幫你向 Container 訂閱了那塊狀態。
2. 安裝與最小可跑範例
安裝
npm install @zerologix/react-river
# 或
yarn add @zerologix/react-river
需求:React 18 以上(peerDependencies: react >=18)。
步驟一:用 <RiverScope> 包住 App
RiverScope 會建立一個 Container 並透過 React Context 往下傳。所有 Hook 都必須在 <RiverScope> 內使用,否則會丟出錯誤。
import { RiverScope } from '@zerologix/react-river';
export default function App() {
return (
<RiverScope>
<MainApp />
</RiverScope>
);
}
步驟二:定義一個 Provider
// providers/counter.ts
import { stateProvider, provider } from '@zerologix/react-river';
// 最簡單的可變狀態,初始值 0
export const counterProvider = stateProvider(() => 0, { name: 'counter' });
// 衍生值:會「watch」counter,counter 一變它就自動重算
export const doubledProvider = provider(
(ref) => ref.watch(counterProvider) * 2,
{ name: 'doubled' },
);
步驟三:在元件裡使用
import { useRiverWatch, useRiverRef } from '@zerologix/react-river';
import { counterProvider, doubledProvider } from './providers/counter';
function Counter() {
const count = useRiverWatch(counterProvider); // 訂閱,值變就重繪
const doubled = useRiverWatch(doubledProvider); // 衍生值也會跟著變
const ref = useRiverRef(); // 命令式操作入口
return (
<div>
<p>Count: {count}(Doubled: {doubled})</p>
<button onClick={() => ref.set(counterProvider, (c) => c + 1)}>+1</button>
</div>
);
}
到這裡你已經跑起一個完整的反應式流程了:按下按鈕 → ref.set 改 counter → doubled 自動重算 → 兩個 useRiverWatch 各自觸發重繪。
💡
name建議一律填寫。 雖然某些簡單情境可以省略,但name是 DevTools 顯示、除錯訊息、以及 SSR 序列化的鍵值。養成永遠給name的習慣,日後省很多事。
3. 核心觀念:Provider 是「定義」不是「狀態」
這是最重要、也最容易誤解的一點。看這行:
export const counterProvider = stateProvider(() => 0, { name: 'counter' });
counterProvider 不是數字 0,它是一個「描述物件」,內容大致像這樣:
{
id: Symbol.for('river:counter'), // 唯一識別(用 name 產生)
kind: 'stateProvider',
name: 'counter',
options: { name: 'counter' },
_create: () => 0, // 「怎麼產生初始值」的食譜
notifier: { ... }, // 存取控制器的子提供者(見第 7 章)
}
真正的數字 0 是在某個元件第一次讀它時,由 Container 執行 _create() 算出來、存起來的。這帶來幾個重要性質:
- Provider 可以在模組頂層宣告、跨檔案 import 共用,不會有「狀態洩漏」問題——因為狀態不在 Provider 上。
- 同一個 Provider 定義,在不同 Scope 有各自獨立的狀態(除非設
global: true)。 - 沒人讀的 Provider 不佔資源;當最後一個訂閱者離開,Container 會自動回收(見第 10 章 autoDispose)。
id 為什麼用 Symbol.for(name)?
React River 用 Symbol.for('river:' + name) 產生 id。這代表同樣的 name 永遠對應同一個 Symbol。好處是熱更新(HMR)時模組重新執行、Provider 物件被重建,但 id 不變,Container 裡的狀態與 override 仍然有效。副作用是:同一個 name 不要用在兩個不同的 Provider 上,否則它們會被視為同一個。
4. 六種 Provider 逐一講解
React River 提供六種 Provider 工廠。先給你一張決策表,再逐一深入。
| 情境 | 用這個 | 讀出來的型別 |
|---|---|---|
| 唯讀的衍生/計算值 | provider |
T |
| 單純可變狀態(外部直接 set) | stateProvider |
T |
| 非同步抓一次資料(唯讀) | promiseProvider |
AsyncValue<T> |
| 串流/訂閱(WebSocket、計時器…) | observableProvider |
AsyncValue<T> |
| 複雜的同步狀態+方法 | notifierProvider + Notifier |
T |
| 複雜的非同步狀態+方法(抓取+變更+reload) | asyncNotifierProvider + AsyncNotifier |
AsyncValue<T> |
挑選心法:
- 只是「算出來給人看」→
provider - 只是「存個值,誰都能改」→
stateProvider - 「抓一次就好、不需要之後手動更新」→
promiseProvider(要帶參數就加Family,見第 9 章) - 「抓取之後還要 reload、新增、樂觀更新」→
asyncNotifierProvider - 「有一堆同步操作方法(新增/切換/刪除待辦)」→
notifierProvider - 「資料是持續推送的串流」→
observableProvider
4.1 stateProvider — 最簡單的可變狀態
何時用: 一個可以被外部直接讀寫的值,沒有複雜邏輯。例如:目前選中的分頁、開關、一個計數器、或注入一個 service 實例。
import { stateProvider } from '@zerologix/react-river';
export const counterProvider = stateProvider(() => 0, { name: 'counter' });
工廠簽章:stateProvider<T>(create: (ref) => T, options): StateProvider<T>
create(ref)回傳初始值。可以用ref.watch(其他 provider)讓初始值依賴別人。
在元件裡讀寫:
const count = useRiverWatch(counterProvider); // 讀(訂閱)
const ref = useRiverRef();
ref.set(counterProvider, 10); // 直接設定
ref.set(counterProvider, (prev) => prev + 1); // 用函式更新(推薦,避免 stale)
set只對stateProvider有效(型別上set<T>(p: StateProvider<T>, ...))。其他 Provider 想改值要靠 Notifier 或 invalidate。
4.2 provider — 衍生/計算值(唯讀)
何時用: 值完全由其他 Provider 推導而來,你不會直接設定它。例如:把清單過濾一遍、把兩個 Provider 合併、格式化。
import { provider } from '@zerologix/react-river';
import { counterProvider } from './counter';
export const doubledProvider = provider(
(ref) => {
const count = ref.watch(counterProvider); // 建立依賴:counter 一變,這裡自動重算
return count * 2;
},
{ name: 'doubled' },
);
重點:ref.watch 建立的是反應式依賴。只要被 watch 的來源改變,這個 provider 就會重新執行、把新值推給所有訂閱它的元件。你永遠不用手動「通知」它更新。
4.3 promiseProvider — 非同步抓一次資料
何時用: 讀取型的非同步資料,抓一次就好(例如載入一篇文章、一份設定)。它會把 Promise 的三個階段包成 AsyncValue(見第 5 章)。
import { promiseProvider } from '@zerologix/react-river';
export const configProvider = promiseProvider(
async (ref) => {
const res = await fetch('/api/config');
if (!res.ok) throw new Error('載入設定失敗');
return res.json();
},
{ name: 'config' },
);
工廠簽章:promiseProvider<T>(create: (ref) => Promise<T>, options): PromiseProvider<T>
在元件裡讀出來的是 AsyncValue<T>:
const config = useRiverWatch(configProvider);
if (config.isLoading) return <Spinner />;
if (config.isError) return <Error e={config.error} />;
return <pre>{JSON.stringify(config.data, null, 2)}</pre>;
想手動重抓?用 ref.invalidate(configProvider) 或 ref.refresh(configProvider)(見第 6.2)。它會重新執行 create,UI 回到 loading。
promiseProvider還帶一個.promise子存取器(configProvider.promise),會給你一個「resolve 出目前/下一筆資料」的 Promise,主要用於 SSR 或需要 await 的場景。
4.4 observableProvider — 串流/訂閱
何時用: 資料會持續推送,例如 WebSocket、setInterval 計時器、RxJS Observable、或任何有 subscribe/unsubscribe 形狀的東西。
create 要回傳一個 ObservableLike<T>(可以是 Promise,代表「先非同步準備連線再訂閱」):
import { observableProvider } from '@zerologix/react-river';
export const clockProvider = observableProvider<string>(
(ref) => ({
subscribe: (callbacks) => {
// callbacks 可能是函式,也可能是 { next, error, complete }
const next = typeof callbacks === 'function' ? callbacks : callbacks.next;
const timer = setInterval(() => next(new Date().toLocaleTimeString()), 1000);
return { unsubscribe: () => clearInterval(timer) };
},
}),
{ name: 'clock' },
);
讀出來一樣是 AsyncValue<T>:連線建立前是 loading,每次推送更新為新的 data。當最後一個訂閱者離開,River 會呼叫你回傳的 unsubscribe() 清理資源。
ObservableLike的形狀:subscribe收「一個next函式」或「{ next, error, complete }物件」,回傳{ unsubscribe }。這跟 RxJS 的 Observable 相容。
4.5 notifierProvider + Notifier — 有方法的同步狀態
何時用: 狀態有多個操作方法、含商業邏輯,但不涉及非同步。經典例子是待辦清單(新增/切換/刪除)。
繼承 Notifier<T>,用 build() 回傳初始值,方法裡用 this.state = ... 更新:
import { Notifier, notifierProvider } from '@zerologix/react-river';
export interface Todo { id: number; text: string; done: boolean; }
let nextId = 1;
class TodoNotifier extends Notifier<Todo[]> {
build(): Todo[] {
return []; // 初始狀態
}
add(text: string) {
this.state = [...this.state, { id: nextId++, text, done: false }];
}
toggle(id: number) {
this.state = this.state.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
}
remove(id: number) {
this.state = this.state.filter((t) => t.id !== id);
}
}
export const todosProvider = notifierProvider(() => new TodoNotifier(), { name: 'todos' });
Notifier 基底類別提供:
build(): T— 你必須實作,回傳初始狀態。this.state(getter / setter)— 讀寫狀態,setter 會自動通知所有訂閱者重繪。this.update(fn)— 便利寫法,等同this.state = fn(this.state)。this.ref— 一個Ref,可以watch/read其他 Provider(見第 8 章)。
在元件裡:值用 useRiverWatch(todosProvider),方法用 .notifier(見第 7 章):
const todos = useRiverWatch(todosProvider); // Todo[]
const notifier = useRiverRef().read(todosProvider.notifier);
// notifier.add('買牛奶'); notifier.toggle(1); notifier.remove(1);
4.6 asyncNotifierProvider + AsyncNotifier — 有方法的非同步狀態
何時用: 需要「非同步抓取 + 之後還要 reload/新增/樂觀更新」的複雜資料層。這是實務上做 API 資料層最常用的一種。
繼承 AsyncNotifier<T>,build() 是 async 且回傳原始 T(River 會自動包成 AsyncValue);其他方法裡則直接操作 this.state(型別是 AsyncValue<T>):
import {
AsyncNotifier,
asyncNotifierProvider,
asyncData,
asyncLoading,
asyncError,
} from '@zerologix/react-river';
export interface User { id: number; name: string; email: string; }
class UserNotifier extends AsyncNotifier<User> {
async build(): Promise<User> {
const res = await fetch('/api/me');
return res.json(); // 回傳原始 User;River 會包成 asyncData(user)
}
// 重新載入:先進 loading(保留舊資料),再換上新資料
async reload() {
this.state = asyncLoading(this.state.data);
try {
const res = await fetch('/api/me');
this.state = asyncData(await res.json());
} catch (e) {
this.state = asyncError(e, this.state.data); // 出錯也保留舊資料
}
}
// 樂觀更新:先改本地,失敗再回滾
async rename(name: string) {
const previous = this.state.data;
if (previous) this.state = asyncData({ ...previous, name });
try {
await fetch('/api/me', { method: 'PATCH', body: JSON.stringify({ name }) });
} catch (e) {
this.state = asyncError(e, previous); // 回滾
throw e;
}
}
}
export const userProvider = asyncNotifierProvider(() => new UserNotifier(), { name: 'user' });
AsyncNotifier 基底類別提供:
build(): Promise<T>— 你必須實作,async,回傳原始資料。this.state(getter / setter)— 型別是AsyncValue<T>,用asyncData/asyncLoading/asyncError設定。this.ref— 同Notifier。
在元件裡讀出來是 AsyncValue<User>,方法一樣透過 .notifier 呼叫:
const user = useRiverWatch(userProvider);
const ref = useRiverRef();
// <button onClick={() => ref.read(userProvider.notifier).reload()}>Reload</button>
notifierProvidervsasyncNotifierProvider怎麼選? 看build()需不需要async。要await抓資料 →asyncNotifierProvider(值是AsyncValue<T>)。純同步邏輯 →notifierProvider(值是T)。
5. AsyncValue:優雅處理 loading / data / error
所有非同步 Provider(promiseProvider、observableProvider、asyncNotifierProvider)讀出來都是 AsyncValue<T>。它是一個「三選一」的聯合型別,把非同步的三種狀態用型別安全的方式表達出來。
三種狀態與欄位
type AsyncValue<T> = AsyncLoading<T> | AsyncData<T> | AsyncError<T>;
| 欄位 | loading | data | error |
|---|---|---|---|
status |
'loading' |
'data' |
'error' |
data |
上一筆資料或 undefined |
T |
上一筆資料或 undefined |
error |
undefined |
undefined |
unknown |
isLoading |
true |
false |
false |
isError |
false |
false |
true |
hasData |
有舊資料才 true |
true |
有舊資料才 true |
注意
hasData:loading 與 error 狀態下也可能有data(例如 reload 時保留舊資料)。想判斷「畫面上到底有沒有東西可顯示」,用hasData比只看status === 'data'更準。
建構子
import { asyncData, asyncLoading, asyncError } from '@zerologix/react-river';
asyncData(user); // 成功,帶資料
asyncLoading(); // 載入中,無舊資料
asyncLoading(previousData); // 載入中,保留舊資料(reload 常用)
asyncError(err); // 失敗
asyncError(err, previousData); // 失敗,但保留舊資料給畫面顯示
用 when 做窮舉式樣式比對(推薦)
比起一堆 if (isLoading) ... if (isError) ...,when 更清楚、且 TypeScript 會強制你處理全部三種狀態:
import { when, useRiverWatch } from '@zerologix/react-river';
function UserView() {
const user = useRiverWatch(userProvider);
return when(user, {
data: (u) => <h1>{u.name}</h1>,
loading: () => <p>載入中…</p>,
error: (e) => <p style={{ color: 'red' }}>錯誤:{String(e)}</p>,
});
}
whenOrNull 是部分比對版本,只處理你在意的狀態,其他回傳 undefined:
{whenOrNull(user, { error: (e) => <Toast message={String(e)} /> })}
其他好用的轉換工具
| 函式 | 用途 |
|---|---|
mapAsyncValue(av, fn) |
保留狀態,只把裡面的 data 用 fn 轉換(例如 User → user.name)。 |
requireData(av) |
硬取出 data,沒有就丟錯。用在你「確定一定有資料」的地方。 |
asyncValueEquals(a, b) |
比較兩個 AsyncValue 是否相等(status 相同且 data/error 以 Object.is 比對)。 |
asyncValueToPromise(av, ...) |
把 AsyncValue 轉回 Promise:data→resolve、error→reject、loading→等它 settle。 |
保留舊資料的重載模式(很重要)
reload 時不要直接 asyncLoading(),那會讓畫面閃一下空白。改成 asyncLoading(this.state.data),畫面就能「顯示舊資料 + 一個小 spinner」,體驗好很多:
async reload() {
this.state = asyncLoading(this.state.data); // ✅ 保留舊資料
const fresh = await fetchData();
this.state = asyncData(fresh);
}
6. 四個 React Hooks
| Hook | 會重繪嗎? | 用途 |
|---|---|---|
useRiverWatch |
✅ 會 | 訂閱一個 Provider 的值 |
useRiverRef |
❌ 不會 | 拿到命令式操作入口(read/set/invalidate…) |
useRiverListen |
❌ 不會 | 值變時跑副作用(導頁、toast…) |
useRiverMutation |
✅ 會(自己的區域狀態) | 命令式非同步操作+樂觀更新 |
6.1 useRiverWatch — 訂閱值(會觸發重繪)
三種呼叫形式:
// 1) 讀整個值
const user = useRiverWatch(userProvider);
// 2) 加 selector:只在「選出來的那一小塊」變動時才重繪
const userName = useRiverWatch(userProvider, (u) => u.data?.name);
// 3) options 物件:selector + enabled(條件式訂閱)
const name = useRiverWatch(userProvider, {
selector: (u) => u.data?.name,
enabled: isLoggedIn, // false 時不訂閱,回傳值可能是 undefined
});
selector 的效能重點(務必理解):
selector 的快取是以「函式參考」為鍵。每次 render 都新建一個 inline 箭頭函式,會讓快取失效、selector 每次 render 都重跑。規則:
- selector 回傳原始值(string/number/boolean)→ inline 寫法安全(用值比較)。
- selector 回傳新物件/陣列(
filter、map、展開)或閉包捕捉了外部 React state → 請用useCallback穩定它,否則每次 render 都會做一次 O(n) 白工(資料量大時是效能地雷)。
// ✅ 穩定:只有 filter 改變時 selector 參考才變
const selectActive = useCallback(
(items: Item[]) => items.filter((i) => i.status === filter),
[filter],
);
const active = useRiverWatch(itemsProvider, selectActive);
// ⚠️ 不穩定:每次 render 都是新函式 → selector 每次都重跑
const active2 = useRiverWatch(itemsProvider, (items) => items.filter(/* ... */));
好消息:即使 selector 不穩定,River 內部也有防護(tearing check 命中快取),不會造成無限重繪;代價只是「多做白工」。所以資料量小時不必過度優化。
6.2 useRiverRef — 命令式操作入口(不重繪)
useRiverRef() 回傳一個穩定的 RiverRef,適合放在事件處理器裡:
const ref = useRiverRef();
ref.read(provider); // 讀一次目前的值(不訂閱、不重繪)
ref.set(stateProvider, value); // 設定 stateProvider 的值
ref.set(stateProvider, (prev) => next); // 用函式更新
ref.invalidate(provider); // 強制該 Provider 重新初始化(下次讀時重跑 create/build)
ref.refresh(provider); // 立即失效並回傳新值
ref.invalidateFamily(family); // 讓某個 family 的所有實例失效(見第 9 章)
ref.listen(provider, (next, prev) => {}); // 手動監聽,回傳 unsubscribe
invalidate vs refresh:兩者都會讓 Provider 重跑。差別是 refresh 會同步回傳新值,invalidate 只是標記失效、等下次被讀時才重算。想「重抓並立刻拿到結果」用 refresh;只是想「叫它下次重新來過」用 invalidate。
6.3 useRiverListen — 值變時跑副作用(不重繪)
當你只想在狀態改變時「做一件事」(導頁、彈 toast、寫 log),而不需要因此重繪,用這個而不是 useRiverWatch:
useRiverListen(authProvider, (next, prev) => {
if (next === null) navigate('/login');
});
callback 拿得到 next 與 prev,方便做「從 A 變成 B 才觸發」的判斷。
6.4 useRiverMutation — 命令式非同步操作(樂觀更新)
useRiverMutation 概念上像 TanStack Query 的 useMutation,但整合進 River。它維護自己的一份區域 AsyncValue 狀態,特別適合「清單/表格裡每一列各自的變更操作」(每個 hook 實例狀態獨立)。
import { useRiverMutation } from '@zerologix/react-river';
function DeleteButton({ id }: { id: string }) {
const { state, mutate, reset } = useRiverMutation(
// mutationFn:收到 (ref, variables),回傳 Promise
async (ref, itemId: string) => {
await ref.read(apiServiceProvider).deleteItem(itemId);
},
{
// 樂觀更新:送出前先把該列從清單移除
onMutate: (itemId, ref) => {
const previous = ref.read(listProvider);
ref.set(listProvider, (list) => list.filter((x) => x.id !== itemId));
return { previous }; // 回傳 context,之後 onError/onSettled 拿得到
},
onError: (_err, _itemId, context, ref) => {
if (context?.previous) ref.set(listProvider, context.previous); // 回滾
},
onSuccess: (data, itemId, context, ref) => {/* 成功 */},
onSettled: (data, err, itemId, context, ref) => {/* 不論成敗都會跑 */},
},
);
return (
<button disabled={state.isLoading} onClick={() => mutate(id)}>
{state.isLoading ? '刪除中…' : '刪除'}
</button>
);
}
回傳物件:
state: AsyncValue<TData | undefined>— 這次變更的狀態(idle 時是asyncData(undefined))。mutate(variables): Promise<TData>— 觸發變更。reset()— 重設回 idle。
生命週期回呼觸發順序:onMutate →(送出)→ 成功 onSuccess / 失敗 onError → 最後一定跑 onSettled。onMutate 回傳的 context 會一路傳給後面三個,這就是樂觀更新/回滾的標準管線。
mutationFn收ref的用意:讓你在函式內用ref.read(...)即時取得 Provider,避免閉包抓到過期的值(stale closure)。所以不要從外層閉包抓 service,改用參數傳進來的ref。
7. 呼叫 Notifier 的方法:.notifier 存取器
notifierProvider / asyncNotifierProvider / stateProvider 都帶一個 .notifier 子存取器,讀它會拿到「實例本身」而不是「狀態值」:
const todos = useRiverWatch(todosProvider); // 拿「值」:Todo[](會重繪)
const notifier = useRiverRef().read(todosProvider.notifier); // 拿「實例」:TodoNotifier(不重繪)
notifier.add('買牛奶');
notifier.toggle(1);
心法:值和方法分開拿。
- 要顯示的資料 →
useRiverWatch(provider)(需要反應式)。 - 要呼叫的方法 →
ref.read(provider.notifier)(方法本身不需要反應式,用read就好,避免多餘訂閱)。
也可以用 useRiverWatch(provider.notifier) 拿實例——因為 Notifier 實例是穩定的,這不會造成額外重繪。但事件處理器裡通常 ref.read(...) 更直接。
// asyncNotifierProvider 呼叫方法的典型寫法
const ref = useRiverRef();
<button onClick={() => ref.read(userProvider.notifier).reload()}>Reload</button>
stateProvider.notifier拿到的是一個StateController<T>(有stategetter/setter 和update)。不過改stateProvider通常直接用ref.set(provider, ...)更簡潔。
8. Provider 之間的依賴:ref.watch vs ref.read
React River 有兩種 ref,容易搞混,先分清楚:
Ref(提供者/Notifier 內部) |
RiverRef(元件內,來自 useRiverRef) |
|
|---|---|---|
| 從哪來 | provider((ref) => ...) 的參數、或 Notifier 裡的 this.ref |
const ref = useRiverRef() |
watch |
✅ 有(建立反應式依賴) | ❌ 沒有(元件請改用 useRiverWatch) |
read |
✅ 有 | ✅ 有 |
listen |
✅ 有 | ✅ 有 |
set / refresh / invalidate(其他) |
❌ 沒有 | ✅ 有 |
invalidateSelf |
✅ 有(讓自己重來) | ❌(用 invalidate(provider)) |
生命週期 onDispose/onCancel/onResume |
✅ 有 | ❌ |
watch 與 read 的差別(關鍵)
ref.watch(other)— 建立反應式依賴。other一變,當前 Provider 就重新計算。用在「我的值需要跟著它同步」。ref.read(other)— 只讀當下的值一次,不建立依賴。之後other變了也不會通知你。用在「我只是暫時看一下、或在事件當下取值」。
export const summaryProvider = provider(
(ref) => {
const user = ref.watch(userProvider); // ✅ user 變 → summary 重算
const config = ref.read(configProvider); // 只在初次計算時看一眼 config,之後不追蹤
return `${user.data?.name} @ ${config.data?.region}`;
},
{ name: 'summary' },
);
在 Notifier 裡同理,用 this.ref.watch(...)(要同步)或 this.ref.read(...)(一次性,通常在方法裡呼叫 API 時用):
class OrdersNotifier extends AsyncNotifier<Order[]> {
async build() {
const api = this.ref.read(apiServiceProvider); // 抓一次 service 就好
return api.fetchOrders();
}
}
經驗法則:
build()裡「決定初始資料要依賴誰」用watch;方法裡「當下要拿 service 或某個值來用」用read。
9. Family:帶參數的 Provider
當 Provider 需要參數(例如「依 ID 抓文章」),用 Family 版本。每種 Provider 都有對應的 xxxFamily:
providerFamily / stateProviderFamily / promiseProviderFamily
observableProviderFamily / notifierProviderFamily / asyncNotifierProviderFamily
Family 是「Provider 的工廠」:你呼叫 family(arg) 得到一個真正的 Provider,同樣的 arg 會回傳同一個快取實例(用穩定序列化當鍵,物件的鍵順序不影響)。
import { promiseProviderFamily } from '@zerologix/react-river';
export interface Post { id: number; title: string; body: string; }
export const postProvider = promiseProviderFamily<Post, number>(
async (ref, postId) => {
const res = await fetch(`/api/posts/${postId}`);
return res.json();
},
{ name: 'post' },
);
// 元件裡:先呼叫 family 帶入參數,再交給 useRiverWatch
const post = useRiverWatch(postProvider(42));
const title = useRiverWatch(postProvider(42), (p) => p.data?.title); // 也能配 selector
⚠️ 工廠函式簽章有兩種,別搞混
這是最容易踩雷的地方——不同 family 的 create 函式參數不一樣:
| Family | create 函式簽章 | 說明 |
|---|---|---|
providerFamily |
(ref, arg) => T |
先 ref 再 arg |
stateProviderFamily |
(ref, arg) => T |
先 ref 再 arg |
promiseProviderFamily |
(ref, arg) => Promise<T> |
先 ref 再 arg |
observableProviderFamily |
(ref, arg) => ObservableLike<T> |
先 ref 再 arg |
notifierProviderFamily |
(arg) => Notifier |
只有 arg,沒有 ref |
asyncNotifierProviderFamily |
(arg) => AsyncNotifier |
只有 arg,沒有 ref |
前四種的 create 拿得到 (ref, arg);但兩種 Notifier family 的工廠只收 arg——因為 Notifier 內部已經有 this.ref 可用,參數只需要把 arg 傳進建構子即可:
import { AsyncNotifier, asyncNotifierProviderFamily, asyncData, asyncLoading } from '@zerologix/react-river';
class PostNotifier extends AsyncNotifier<Post> {
constructor(private postId: number) {
super(); // ← 記得呼叫 super()
}
async build(): Promise<Post> {
const api = this.ref.read(apiServiceProvider); // 用 this.ref,不是工廠參數
return api.fetchPost(this.postId);
}
async reload() {
this.state = asyncLoading(this.state.data);
const api = this.ref.read(apiServiceProvider);
this.state = asyncData(await api.fetchPost(this.postId));
}
}
export const postDetailProvider = asyncNotifierProviderFamily(
(postId: number) => new PostNotifier(postId), // ✅ 只有一個參數 postId
{ name: 'postDetail' },
);
// 用:useRiverWatch(postDetailProvider(42))
// ref.read(postDetailProvider(42).notifier).reload()
Family 的管理方法
postProvider.getProviders(); // 目前快取的所有實例
postProvider.clear(); // 清空快取
useRiverRef().invalidateFamily(postProvider); // 讓所有實例失效重抓
10. 生命週期與快取
自動回收(autoDispose)
預設情況下,當一個 Provider 的最後一個訂閱者離開,它會被回收、狀態清掉;下次再有人讀它時會重新初始化。這讓你不用手動管理記憶體。
透過 options 調整:
| 選項 | 預設 | 作用 |
|---|---|---|
autoDispose |
true |
沒訂閱者就回收。設 false 可讓狀態常駐。 |
cacheTime |
0(毫秒) |
最後訂閱者離開後,額外保留多久才回收(僅 autoDispose: true 有效)。適合「切走再切回來」不想重抓。 |
global |
false |
true 時共用最頂層(root)Container 的狀態,跨所有 Scope 同一份。 |
equals |
Object.is(非同步另有 AsyncValue 比較) |
自訂「新舊值是否相等」,相等就不通知更新。 |
export const sessionProvider = stateProvider(() => loadSession(), {
name: 'session',
autoDispose: false, // 整個 App 生命週期常駐
global: true, // 所有 Scope 共用同一份
});
export const searchResultProvider = promiseProviderFamily(
(ref, q: string) => search(q),
{ name: 'searchResult', cacheTime: 30_000 }, // 離開後保留 30 秒
);
也可以在 <RiverScope cachePolicy={{ autoDispose: true, cacheTime: 5000 }}> 設整個 Scope 的預設值,個別 Provider 仍可覆寫。
在 Provider/Notifier 內掛生命週期 callback
Ref 提供幾個生命週期掛鉤(在 provider(create) 的 ref、或 Notifier 的 this.ref 上):
export const socketProvider = provider(
(ref) => {
const socket = openSocket();
ref.onDispose(() => socket.close()); // 這個 Provider 被回收時
ref.onCancel(() => socket.pause()); // 最後一個訂閱者離開時
ref.onResume(() => socket.resume()); // cancel 之後又有新訂閱者時
return socket;
},
{ name: 'socket' },
);
onDispose— Provider 被銷毀時清理(關連線、清 timer)。onCancel/onResume— 訂閱者歸零/回來,適合暫停與恢復昂貴資源。ref.invalidateSelf()— Provider 主動要求自己重新初始化。
Notifier 上也有
this.onDispose(cb),但已標記 deprecated,請改用this.ref.onDispose(cb)保持一致。
11. Scope 與 Overrides(隔離與測試)
<RiverScope> 可以巢狀。內層 Scope 會建立自己的 Container,且繼承外層;你可以用 overrides 在某個子樹替換 Provider 的實作或初始值。這對測試、Storybook、或「同頁面多份獨立狀態」超好用。
import { RiverScope } from '@zerologix/react-river';
import { counterProvider } from './providers/counter';
<>
{/* 這裡的 counter 從 0 開始(預設) */}
<ScopedCounter label="Root" />
{/* 這個子樹的 counter 從 1000 開始,其餘 Provider 照舊 */}
<RiverScope overrides={[{ original: counterProvider, create: () => 1000 }]}>
<ScopedCounter label="Scope B" />
</RiverScope>
</>
override 的形狀是 { original, create }:original 是要替換的 Provider,create(ref) 是新的產生函式(回傳新初始值或 mock 實例)。衍生的 Provider(例如 doubled)會自動基於被覆寫後的來源重算,不用改任何東西。
測試時最常見的用法是把「真的 service」換成 mock:
<RiverScope
overrides={[
{ original: apiServiceProvider, create: () => new MockApiService() },
]}
>
<ComponentUnderTest />
</RiverScope>
12. SSR 伺服器端渲染
React River 內建 SSR 脫水/注水,讓伺服器算好的狀態直接帶到瀏覽器、避免載入閃爍。流程三步:
步驟 0:Provider 一定要有 name。 它是脫水物件的鍵。(想排除某個 Provider,設 ssr: false,例如敏感 token。)
步驟 1:伺服器端脫水
import { RiverContainer } from '@zerologix/react-river';
const container = new RiverContainer();
await container.read(userProvider.promise); // 先 await 非同步 Provider 完成
const initialRiverState = container.dehydrate();
// => { "user": { id: 1, name: "John" } }
步驟 2:客戶端注水
<RiverScope initialState={pageProps.initialRiverState}>
<App />
</RiverScope>
運作方式:客戶端第一次讀某個 Provider 時,如果 initialState 有對應的鍵,非同步 Provider 會直接以 asyncData(注水值) 呈現(沒有 loading 閃爍),同時背景靜默重抓(stale-while-revalidate),新資料到了再更新。每個 key 用過一次就刪除;refresh()/invalidate() 永遠重新抓、不會再用舊的注水值。
自訂序列化(類別實例 ↔ 純物件):
export const userProvider = stateProvider(() => new User(0, 'Guest'), {
name: 'user',
toJSON: (u) => ({ id: u.id, name: u.name }), // 伺服器:類別 → 純物件
fromJSON: (j) => new User(j.id, j.name), // 客戶端:純物件 → 類別
});
(完整 Next.js 範例見 react-river 專案內的 SSR_NEXTJS_EXAMPLE.md。)
13. DevTools 與觀察者
把 <RiverDevTools /> 放進 <RiverScope> 內,就有一個浮動面板可以檢視 Provider 值、事件、依賴圖:
import { RiverScope, RiverDevTools } from '@zerologix/react-river';
<RiverScope>
<App />
<RiverDevTools /> {/* 自動綁定最近的 RiverScope */}
</RiverScope>
想在 console 印出狀態轉換的 log,掛上 loggerObserver:
import { loggerObserver } from '@zerologix/react-river';
<RiverScope observers={[loggerObserver]}>
<App />
</RiverScope>
observers 收的是 RiverObserver[],你也可以自訂觀察者來接生命週期事件(做 analytics、時間旅行等)。
14. Consumer:不用 Hook 的情境
如果你在 class component、或某些不能用 Hook 的地方需要讀 Provider,用 render-prop 版的 <Consumer>:
import { Consumer } from '@zerologix/react-river';
<Consumer>
{(ref) => {
const count = ref.watch(counterProvider); // 這裡的 ref 額外有 watch(會重繪)
return <button onClick={() => ref.set(counterProvider, (c) => c + 1)}>{count}</button>;
}}
</Consumer>
Consumer 傳給你的 ConsumerRef 是 RiverRef 再加上一個 watch(訂閱並在變動時重繪整個 render-prop)。一般函式元件請優先用 Hooks,Consumer 是備援方案。
15. 完整實戰範例
把前面的觀念串起來:一個「使用者資料卡 + 待辦清單」的小功能。假設有一個注入的 API service。
// providers/services.ts — 用 stateProvider 注入 service 實例
import { stateProvider } from '@zerologix/react-river';
import type { ApiService } from '@/services/ApiService';
export const apiServiceProvider = stateProvider<ApiService | null>(() => null, {
name: 'apiService',
});
// providers/user.ts — 非同步資料層,含 reload
import { AsyncNotifier, asyncNotifierProvider, asyncData, asyncLoading, asyncError } from '@zerologix/react-river';
import { apiServiceProvider } from './services';
import type { User } from '@/services/ApiService';
class UserNotifier extends AsyncNotifier<User | null> {
async build(): Promise<User | null> {
const api = this.ref.watch(apiServiceProvider); // service 換了就重建
if (!api) return null;
return api.fetchMe();
}
async reload() {
this.state = asyncLoading(this.state.data);
try {
const api = this.ref.read(apiServiceProvider);
if (!api) return;
this.state = asyncData(await api.fetchMe());
} catch (e) {
this.state = asyncError(e, this.state.data);
}
}
}
export const userProvider = asyncNotifierProvider(() => new UserNotifier(), { name: 'user' });
// providers/todos.ts — 同步狀態層,含多個方法
import { Notifier, notifierProvider } from '@zerologix/react-river';
export interface Todo { id: number; text: string; done: boolean; }
let nextId = 1;
class TodoNotifier extends Notifier<Todo[]> {
build(): Todo[] { return []; }
add(text: string) { this.state = [...this.state, { id: nextId++, text, done: false }]; }
toggle(id: number) { this.state = this.state.map((t) => t.id === id ? { ...t, done: !t.done } : t); }
remove(id: number) { this.state = this.state.filter((t) => t.id !== id); }
}
export const todosProvider = notifierProvider(() => new TodoNotifier(), { name: 'todos' });
// 衍生值:未完成數量
import { provider } from '@zerologix/react-river';
export const remainingProvider = provider(
(ref) => ref.watch(todosProvider).filter((t) => !t.done).length,
{ name: 'remaining' },
);
// components/Dashboard.tsx
import { useState } from 'react';
import { useRiverWatch, useRiverRef, when } from '@zerologix/react-river';
import { userProvider } from '@/providers/user';
import { todosProvider, remainingProvider } from '@/providers/todos';
export function Dashboard() {
const user = useRiverWatch(userProvider); // AsyncValue<User | null>
const todos = useRiverWatch(todosProvider); // Todo[]
const remaining = useRiverWatch(remainingProvider); // number
const ref = useRiverRef();
const [input, setInput] = useState('');
const todoActions = ref.read(todosProvider.notifier);
return (
<div>
{/* 使用者卡:用 when 窮舉三種狀態 */}
{when(user, {
loading: () => <p>載入使用者中…</p>,
error: (e) => <p>載入失敗:{String(e)}</p>,
data: (u) => (
<header>
<h2>{u?.name ?? '訪客'}</h2>
<button onClick={() => ref.read(userProvider.notifier).reload()}>重新整理</button>
</header>
),
})}
{/* 待辦清單 */}
<h3>待辦(還剩 {remaining} 項)</h3>
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button
onClick={() => {
if (input.trim()) { todoActions.add(input.trim()); setInput(''); }
}}
>
新增
</button>
</div>
<ul>
{todos.map((t) => (
<li key={t.id}>
<input type="checkbox" checked={t.done} onChange={() => todoActions.toggle(t.id)} />
<span style={{ textDecoration: t.done ? 'line-through' : 'none' }}>{t.text}</span>
<button onClick={() => todoActions.remove(t.id)}>刪除</button>
</li>
))}
</ul>
</div>
);
}
// App.tsx — 用 override 注入真正的 service
import { RiverScope, RiverDevTools } from '@zerologix/react-river';
import { apiServiceProvider } from '@/providers/services';
import { RealApiService } from '@/services/ApiService';
import { Dashboard } from '@/components/Dashboard';
export default function App() {
return (
<RiverScope overrides={[{ original: apiServiceProvider, create: () => new RealApiService() }]}>
<Dashboard />
<RiverDevTools />
</RiverScope>
);
}
這個範例示範了:service 注入(stateProvider + override)、非同步資料層(asyncNotifierProvider + when + reload)、同步狀態層(notifierProvider 多方法)、衍生值(provider + ref.watch)、以及值/方法分開取用(useRiverWatch vs .notifier)。
16. 常見錯誤與最佳實務
| ❌ 別這樣 | ✅ 改這樣 | 原因 |
|---|---|---|
在元件裡想用 useRiverRef().watch(...) |
用 useRiverWatch(provider) |
元件的 RiverRef 沒有 watch;反應式訂閱要用 Hook |
notifier family 工廠寫成 (ref, arg) => new X(arg) |
寫成 (arg) => new X(arg) |
Notifier family 工廠只收 arg,ref 用 this.ref |
reload 時用 asyncLoading() |
用 asyncLoading(this.state.data) |
保留舊資料,避免畫面閃空白 |
| 大資料量用 inline selector 回傳新陣列 | 用 useCallback 包 selector |
否則每次 render 都重跑 selector(白工) |
兩個不同 Provider 取相同 name |
每個 Provider name 唯一 |
id = Symbol.for(name),同名會被當同一個 |
在事件處理器裡 useRiverWatch 拿值 |
用 ref.read(provider) |
事件當下取值不需要訂閱 |
| Notifier 方法裡用外層閉包的 service | 用 this.ref.read(serviceProvider) |
避免 stale closure |
忘了 <RiverScope> 就用 Hook |
根部包 <RiverScope> |
Hook 必須在 Scope 內,否則丟錯 |
build() 依賴別人卻用 read |
要同步就用 watch,一次性才用 read |
read 不建立依賴,來源變了不會重算 |
其他建議:
- 一律給
name:DevTools、除錯、SSR 都靠它。 - 值和方法分開取:顯示用
useRiverWatch(provider),操作用ref.read(provider.notifier)。 - 樂觀更新走
onMutate/onError管線(useRiverMutation)或在 AsyncNotifier 方法裡「先改本地、失敗回滾」。 - 檔案組織:慣例把 Provider 放在
src/river/(或src/providers/),一個功能一個檔,再用index.ts匯出。命名慣例見下表。
| 實體 | 慣例 | 範例 |
|---|---|---|
| Provider 變數 | camelCase + Provider |
userProvider |
| Notifier 類別 | PascalCase + Notifier |
UserNotifier |
name 選項 |
全域唯一字串(常用 PascalCase 或短名) | 'user' / 'UserProvider' |
| Provider 檔案 | camelCase,放 src/river/ |
user.ts |
17. API 速查表
Provider 工廠
provider<T>(create: (ref) => T, options): Provider<T>
stateProvider<T>(create: (ref) => T, options): StateProvider<T>
promiseProvider<T>(create: (ref) => Promise<T>, options): PromiseProvider<T>
observableProvider<T>(create: (ref) => ObservableLike<T> | Promise<...>, options): ObservableProvider<T>
notifierProvider<N>(createNotifier: () => N, options): NotifierProvider<N, T>
asyncNotifierProvider<N>(createNotifier: () => N, options): AsyncNotifierProvider<N, T>
Family 工廠(注意 Notifier 家族的工廠只收 arg)
providerFamily<T, Arg>((ref, arg) => T, options)
stateProviderFamily<T, Arg>((ref, arg) => T, options)
promiseProviderFamily<T, Arg>((ref, arg) => Promise<T>, options)
observableProviderFamily<T, Arg>((ref, arg) => ObservableLike<T>, options)
notifierProviderFamily<N, Arg>((arg) => N, options) // 只有 arg
asyncNotifierProviderFamily<N, Arg>((arg) => N, options) // 只有 arg
// family(arg) → Provider;family.clear() / family.getProviders()
Notifier 基底類別
abstract class Notifier<T> {
abstract build(): T;
get/set state: T;
update(fn: (cur: T) => T): void;
get ref: Ref;
}
abstract class AsyncNotifier<T> {
abstract build(): Promise<T>;
get/set state: AsyncValue<T>;
get ref: Ref;
}
AsyncValue
type AsyncValue<T> = { status; data; error; isLoading; isError; hasData }
asyncData(data) / asyncLoading(prev?) / asyncError(err, prev?)
when(av, { data, loading, error }) // 窮舉比對
whenOrNull(av, { data?, loading?, error? }) // 部分比對
mapAsyncValue(av, fn) / requireData(av) / asyncValueEquals(a, b) / asyncValueToPromise(av, ...)
Hooks
useRiverWatch(provider) // 訂閱值
useRiverWatch(provider, selector) // 選取子集
useRiverWatch(provider, { selector, enabled }) // 條件式訂閱
useRiverRef() // → { read, set, invalidate, invalidateFamily, refresh, listen }
useRiverListen(provider, (next, prev) => void) // 副作用,不重繪
useRiverMutation(fn, options?) // → { state, mutate, reset }
Ref(提供者/Notifier 內部)
ref.watch(provider) | ref.watch(provider, select) // 反應式依賴
ref.read(provider) // 一次性讀取
ref.listen(provider, cb)
ref.onDispose(cb) / ref.onCancel(cb) / ref.onResume(cb)
ref.invalidateSelf() / ref.invalidateFamily(family)
React 元件
<RiverScope overrides? observers? cachePolicy? initialState?>
<RiverDevTools />
<Consumer>{(ref) => ...}</Consumer> // ref 額外有 watch
loggerObserver
存取器(accessor)
provider.notifier // notifierProvider / asyncNotifierProvider / stateProvider → 拿實例/控制器
provider.promise // promiseProvider / observableProvider / asyncNotifierProvider → 拿 Promise
常用 options
{ name, autoDispose?, cacheTime?, global?, ssr?, toJSON?, fromJSON?, equals? }
這份教學對應
@zerologix/react-river的公開 API。想快速產生樣板程式碼,搭配同目錄的SKILL.md使用;想看更底層的依賴追蹤與狀態傳播機制,參考 react-river 專案內的ARCHITECTURE.md。