React River 完整教學

React River 完整教學

@zerologix/react-river — 一套受 Flutter Riverpod 啟發、輕量且高效能的 React 狀態管理函式庫。

這份文件是「從零到熟練」的教學。想快速產生程式碼樣板,請看同目錄的 SKILL.md;想真正搞懂每個 API 為什麼這樣設計、什麼時候用哪個,請讀這一份。


目錄

  1. 先建立心智模型
  2. 安裝與最小可跑範例
  3. 核心觀念:Provider 是「定義」不是「狀態」
  4. 六種 Provider 逐一講解
  5. AsyncValue:優雅處理 loading / data / error
  6. 四個 React Hooks
  7. 呼叫 Notifier 的方法:.notifier 存取器
  8. Provider 之間的依賴:ref.watch vs ref.read
  9. Family:帶參數的 Provider
  10. 生命週期與快取
  11. Scope 與 Overrides(隔離與測試)
  12. SSR 伺服器端渲染
  13. DevTools 與觀察者
  14. Consumer:不用 Hook 的情境
  15. 完整實戰範例
  16. 常見錯誤與最佳實務
  17. 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.setcounterdoubled 自動重算 → 兩個 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,可以 watchread 其他 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>

notifierProvider vs asyncNotifierProvider 怎麼選?build() 需不需要 async。要 await 抓資料 → asyncNotifierProvider(值是 AsyncValue<T>)。純同步邏輯 → notifierProvider(值是 T)。


5. AsyncValue:優雅處理 loading / data / error

所有非同步 Provider(promiseProviderobservableProviderasyncNotifierProvider)讀出來都是 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) 保留狀態,只把裡面的 datafn 轉換(例如 Useruser.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 回傳新物件/陣列filtermap、展開)閉包捕捉了外部 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 拿得到 nextprev,方便做「從 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 → 最後一定跑 onSettledonMutate 回傳的 context 會一路傳給後面三個,這就是樂觀更新/回滾的標準管線。

mutationFnref 的用意:讓你在函式內用 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>(有 state getter/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 ✅ 有

watchread 的差別(關鍵)

  • 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 refarg
stateProviderFamily (ref, arg) => T refarg
promiseProviderFamily (ref, arg) => Promise<T> refarg
observableProviderFamily (ref, arg) => ObservableLike<T> refarg
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 傳給你的 ConsumerRefRiverRef 再加上一個 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 工廠只收 argrefthis.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