Skip to content

useSyncExternalStore

useSyncExternalStore 是一個 React 18 引入的 hook,用於用於從外部儲存(例如狀態管理庫、瀏覽器 API 等)獲取狀態並在元件中同步顯示。對於需要訂閱外部資料源並在資料變更時更新元件的情況很有用。

常見場景

  1. 訂閱外部 store:當你使用像 Redux、Zustand 或其他狀態管理庫時。
  2. 與瀏覽器 API 交互:例如 storage 事件、location 變更等。
  3. 抽離狀態邏輯:用自訂 hooks 抽離狀態邏輯。
  4. SSR 支援。

語法

ts
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

參數:

  • subscribe: 用來訂閱資料來源變更的函式。它接收一個 callback,當資料變更時會呼叫該 callback,然後會回傳一個取消訂閱的函式。
  • getSnapshot: 用來獲取當前資料快照的函式。(即目前狀態)
  • getServerSnapshot(可選): 在 SSR 時用來取得資料來源的快照。

回傳值 res 是目前的資料快照,讓我們在渲染邏輯中使用。

ts
const subscribe = (callback: () => void) => {
    // 訂閱
    callback() 
    return () => { 
        // 取消訂閱
    }
}

const getSnapshot = () => {
    return data
}

const res = useSyncExternalStore(subscribe, getSnapshot)

範例

訂閱瀏覽器 API 實作自訂 hook(useStorage

我們要實作一個 useStorage 的自訂 hook,用來訂閱 localStorage 的資料。讓我們可以確保元件在 localStorage 變更時自動更新。

目標是能夠存資料到 localStorage,並在不同瀏覽器分頁中同步更新這些狀態。

hooks/useStorage.ts
tsx
import { useCallback, useSyncExternalStore } from 'react';

/**
 * 用於同步 localStorage 與 React 狀態的 Hook
 * 使用 useSyncExternalStore 確保跨分頁同步,當其他分頁修改 localStorage 時會自動更新
 * 
 * 注意:瀏覽器的 storage 事件只在「不同分頁」修改 localStorage 時觸發,
 * 同一分頁內的修改不會自動觸發該事件。因此在 updateStorage 中需要手動 dispatch 事件來通知更新。
 * 
 * @param key - localStorage 的鍵名
 * @param initialValue - 當 localStorage 中沒有對應值時使用的預設值
 * @returns 包含當前值與更新函式的 tuple `[value, setValue]`
 */
export function useStorage<T>(key: string, initialValue: T): [T, (newValue: T) => void] {
  const subscribe = useCallback((callback: () => void) => {
    window.addEventListener('storage', callback);

    return () => {
      window.removeEventListener('storage', callback);
    };
  }, []);

  const getSnapshot = useCallback((): T => {
    const storedValue = localStorage.getItem(key);
    if (storedValue !== null) {
      return JSON.parse(storedValue) as T;
    }
    return initialValue;
  }, [key, initialValue]);

  const value = useSyncExternalStore(subscribe, getSnapshot);

  const updateStorage = useCallback(
    (newValue: T) => {
      localStorage.setItem(key, JSON.stringify(newValue));
      // 手動觸發 storage 事件
      window.dispatchEvent(new StorageEvent('storage', { key }));
    },
    [key]
  );

  return [value, updateStorage];
}

支援 SSR 的版本

如果你的應用需要支援 Server-Side Rendering(例如 Next.js),需要提供 getServerSnapshot 來避免水合錯誤:

tsx
const getServerSnapshot = useCallback((): T => {
  // 伺服器端無法存取 localStorage,回傳初始值
  return initialValue;
}, [initialValue]);

const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

然後在元件中,我們就可以直接使用這個 useStorage hook 來同步 localStorage 的資料,並且在重新整理或是其他分頁修改資料時自動更新。

App.tsx
tsx
import { useStorage } from './hooks/useStorage';

function App() {
  const [val, setVal] = useStorage('data', 1);

  return (
    <>
      <h3>{val}</h3>
      <button onClick={() => setVal(val + 1)}>設定val</button>
    </>
  );
}

訂閱 history 實作路由跳轉

實作一個簡易的 useHistory hook,取得瀏覽器 url 狀態以及參數

hooks/useHistory.ts
tsx
import { useSyncExternalStore } from 'react';

export function useHistory() {
  const subscribe = (callback: () => void) => {
    window.addEventListener('popstate', callback);
    window.addEventListener('hashchange', callback);

    return () => {
      window.removeEventListener('popstate', callback);
      window.removeEventListener('hashchange', callback);
    };
  };

  const getSnapshot = () => {
    return window.location.href;
  };

  const url = useSyncExternalStore(subscribe, getSnapshot);

  const push = (url: string) => {
    window.history.pushState(null, '', url);
    window.dispatchEvent(new PopStateEvent('popstate'));
  };

  const replace = (url: string) => {
    window.history.replaceState(null, '', url);
    window.dispatchEvent(new PopStateEvent('popstate'));
  };

  return [url, push, replace] as const;
}

支援 SSR 的版本

如果需要支援 SSR,添加 getServerSnapshot

tsx
const getServerSnapshot = () => {
  // 伺服器端回傳空字串或預設 URL
  return '';
};

const url = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

接著在元件中使用這個 useHistory hook 來取得目前的 url,並且可以進行路由跳轉。

App.tsx
tsx
import { useHistory } from './hooks/useHistory';

function App() {
  const [history, push, replace] = useHistory();

  return (
    <>
      <div>當前url:{history}</div>
      <button
        onClick={() => {
          push('/aaa');
        }}
      >
        跳轉 - push
      </button>
      <button
        onClick={() => {
          replace('/bbb');
        }}
      >
        替換 - replace
      </button>
    </>
  );
}

效果呈現:

  • history 會得到目前的 url。每當 url 變化時,useSyncExternalStore 會自動觸發更新,使 history 反映最新的 url。
  • pushreplace 函式分別用於新增和替換瀏覽器的歷史紀錄。

注意事項

1. getSnapshot 必須回傳穩定的值

getSnapshot 應該在狀態相同時回傳相同的引用,避免不必要的重新渲染。對於物件或陣列,建議使用序列化或是 immutable 資料結構。

2. subscribe 函式應該保持穩定

使用 useCallback 包裹 subscribe 函式,確保依賴項不變時函式引用保持穩定,避免重複訂閱和取消訂閱。

3. 避免在 getSnapshot 中執行副作用

getSnapshot 應該是純函式,只負責讀取和回傳當前狀態,不應該修改狀態或執行其他副作用。

4. SSR 水合問題

在使用 SSR 時,如果不提供 getServerSnapshot,可能會導致客戶端與伺服器端渲染不一致,造成水合錯誤。務必根據實際情況提供適當的 getServerSnapshot

5. 瀏覽器 API 的可用性

當使用瀏覽器 API(如 localStoragewindow.location 等)時,需要注意這些 API 在伺服器端不可用。在 SSR 環境中,應該在 getServerSnapshot 中提供合理的預設值。

參考資料