· Joseph · frontend  · 5 min read

讓sessionStorge結合useSyncExternalStore,一更新其他components都知道

前一陣子遇到一個問題,一直在嘗試如何讓sessionStorage / localStorage / cookie有變動時,react遙遠的其他元件也可以知道其變化。
於是乎就找到了一個codeSandbox,其原理就是透過 useSyncExternalStore 去通知有subscribe的元件,讓他們知道sessionStorage / localStorage / cookie key-value有變動,

前一陣子遇到一個問題,一直在嘗試如何讓sessionStorage / localStorage / cookie有變動時,react遙遠的其他元件也可以知道其變化。 於是乎就找到了一個codeSandbox,其原理就是透過 useSyncExternalStore 去通知有subscribe的元件,讓他們知道sessionStorage / localStorage / cookie key-value有變動,

sessionStore

原先codeSandbox的寫法裡,會發現subscribe會add到不屬於同個itemName的listener,導致notify forEach的次數增加。雖然最終會因為useSyncExternalStore裡checkIfSnapshotChanged沒變,讓實際update到的元件維持在被改變sessionStorage key-value pairs的元件。不過讓forEach少跑一點也不是件壞事。

這邊稍微改一下讓cache可以跟sessionStorage一樣的key-value pairs,同時讓subscribe需要通知的listeners只有key對應到的useSyncExternalStore。做法就是先讓sessionStore改為higher-order function,讓第一階(first-order)有個cache,其儲存的內容與sessionStorage get, set, and remove 一致,然後回傳higher-order sessionStore,讓他們各自有各自的listeners。最後要export出來的其實是 const store = sessionStore();,這樣就完成第一部的改寫了。

可以觀察notify裡listeners.size的數量

reactiveSessionStorage/sessionStore.ts

type Listener = () => void;

// higher-order function
const sessionStore = () => {
  // First-order scoped, create cache when sessionStore initialized
  const cache = new Map<string, any>();

  return () => {
    // Higher-order scoped, each store has a listeners
    const listeners = new Set<Listener>();
    const subscribe = (listener: Listener) => {
      listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    };

    const notify = () => listeners.forEach((l) => l());

    const get = <T>(itemName: string) => {
      const value = cache.get(itemName);
      if (value) return value;
      const sessionValue = sessionStorage.getItem(itemName);
      if (!sessionValue) return undefined;

      const cachedValue = JSON.parse(sessionValue) as T;
      cache.set(itemName, cachedValue);
      return cachedValue;
    };

    const update = <T>(itemName: string, v: T) => {
      cache.set(itemName, v);
      sessionStorage.setItem(itemName, JSON.stringify(v));
      notify();
    };

    const remove = (itemName: string) => {
      cache.delete(itemName);
      sessionStorage.removeItem(itemName);
      notify();
    };

    return {
      subscribe,
      get,
      update,
      remove,
    };
  };
};

// export this store
const store = sessionStore();

export type SessionStoreType = ReturnType<typeof store>;
export default store;

Singleton class

再做個higher-order function去create storage讓storeMap在first-order function內,然後return (itemName: string) => SessionStoreType形式的function。

這邊也可以改寫成Singleton class + getStore function,讓我們不必重複建立sessionStore,後面也可以直接呼叫singleton getStore來操作先前建立過的sessionStore的get, set, update function

reactiveSessionStorage/createReactiveSessionStorage.ts

import sessionStore from './sessionStore';
import type { SessionStoreType } from './sessionStore';

const createReactiveSessionStorage = () => {
  const storeMap: Map<string, SessionStoreType> = new Map();
  return (itemName: string) => {
    if (storeMap.get(itemName)) return storeMap.get(itemName);
    storeMap.set(itemName, getSessionStore());
    return storeMap.get(itemName);
  };
};

export const getStorage = createReactiveSessionStorage();

custom hooks: useReactiveSessionStorage, useSessionActions

最後,把他們跟useSyncExternalStore綁在一起,變成一個custom hook,

  • useReactiveSessionStorage訂閱useSyncExternalStore,同時return value, update function, remove function
  • useSessionActions,很純粹的操作 getStorage(itemName),不依賴useSyncExternalStore

reactiveSessionStorage/useReactiveSessionStorage.ts

'use client';
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';

import type { SessionStoreType } from './sessionStore';
import { getStorage } from './createReactiveSessionStorage';

const useReactiveSessionStorage = <T>(itemName: string) => {
  const storeRef = useRef<SessionStoreType>(getStorage(itemName));

  const [selector, update, remove] = useMemo(
    () => [
      // selector
      (): T => storeRef.current?.get<T>(itemName),
      // update
      (v: T) => storeRef.current?.update<T>(itemName, v),
      // remove
      () => storeRef.current?.remove(itemName),
    ],
    [itemName]
  );

  const value = useSyncExternalStore(storeRef.current.subscribe, selector, selector);
  return [value, update, remove] as const;
};

export const useSessionActions = <T>(itemName: string) => {
  const actions = useMemo(
    () => [
      // update
      (v: T) => getStorage(itemName).update<T>(itemName, v),
      // remove
      () => getStorage(itemName).remove(itemName),
    ],
    [itemName]
  );

  return actions;
};

export default useReactiveSessionStorage;

這樣一個設計就可以讓sessionStorage有變動的時候也update使用useReactiveSessionStorage custom hook的元件。

那再來看看chatGPT怎麼說: https://chatgpt.com/s/t_6854dd517540819187228164a5ebe8ef

然後看看perplexity怎麼說: https://www.perplexity.ai/search/how-to-use-session-with-usesyn-hUmoT5lMQIeDIAz31ysNpQ#0

AI的方法比較傾向使用window event listerner,Perplexity又相對簡潔,更換storage只要換eventListener就好。而我的做法是換storage higher-order function。除了這些方法外,zustand state management套件也有Persisting store可以用,三種寫法各有利弊,再讓需要的人看看。

References

  1. reactive-cookie-store: https://codesandbox.io/p/sandbox/reactive-cookie-store-0g67ps
  2. Zustand: https://zustand.docs.pmnd.rs/integrations/persisting-store-data#simple-example
Back to Blog

Related Posts

View All Posts »

A powerful react hook - useSyncExternalStore

It is an interesting hook called useSyncExternalStore came from React 18. After I went through the doc, I had totally no idea about how to use it and why it works. Luckily, I got a task I thought I can use this hook, and meanwhile I traced the source code to understand useSyncExternalStore implementation. In this article, I will explain how it works internally and show a demo which is differ from the doc. TOC

I18n in NextJS 13 app dir

I used to implement I18n with next-i18next in Next.js. However, after upgrading Next.js 13, there is no need for next-i18next. Instead, you can directly use i18next and react-i18next by this tutorial. Meanwhile, the official doc shows how to use internationalization in app dir. and also provide an example of using next-intl. In this blog, I'll demostrate how to use next-intl in Next.js 13 app dir.

How to use React hook - useImperativeHandle

How to use React hook - useImperativeHandle

Today, We're going to introduce the way to use a great and useful React hook - useImperativeHandle. Have you ever think what is meaning of Imperative? Imperative In grammar, a clause that is in the imperative, or in the imperative mood, contains the base form of a verb and usually has no subject. Examples are ' Go away' and ' Please be careful'. Clauses of this kind are typically used to tell someone to do something. So we can just think useImperativeHandle is Let ref access the handle. Go back to useImperativeHandle definition: useImperativeHandle is a React Hook that lets you customize the handle exposed as a ref. Right? Okay, before starting it, we have to recap how we do if we don't use it.

Use Grafana MCP with Gemini and n8n

Use Grafana MCP with Gemini and n8n

The Model Context Protocol (MCP) is extremely useful. An AI assistant helps you decide when and how to use connected tools, so you only need to configure them. After integrating MCP logging management systems into several of my projects, it has saved me a significant amount of time. In this article, I'm going to integrate Grafana with the Gemini CLI and n8n. I will chat with the Gemini CLI and n8n and have them invoke the Grafana MCP server. structure TOC