Well in Time
Jōtai 介紹
02-27-202111 Min Read

好歌分享:YOASOBI - 向夜晚奔去 / THE HOME TAKE

前言

前端狀態管理方式百百種,但大致上可以分為兩類:

一種是與 UI view library 綁在一起的,以 React 為例,React state、Context API 與去年剛推出的實驗性套件 Recoil 就屬於這種,主要將狀態資料存在 React tree 中。

另一種則是 view-layer agnostic library,資料存在外部 store,讓你可以套用在任何 UI framework 或 view library,如最常見的 Redux、Mobx 等。

再往下細分可以用 Mental modal 分為:Flux、Proxy 與 Atomic 等三種狀態管理邏輯,其中 Flux(Redux)與 Proxy(Mobx)算是出來比較久的,而 Atomic 則是隨著 Recoil 的推出而興起,今天就是想來了解一下 Atomic 的概念是什麼,建構在其上的套件用起來是如何。

但是,今天我想介紹的不是 Recoil,而是一個與 Recoil 採用同樣概念,但 API 與整體 bundle size 小非常多的 Jōtai。

minified + gzipped 後的大小,Jōtai: 3.3kb vs Recoil: 14kb

這也是我想從 jotai 切入的原因,因為簡單的 API 與輕量的 bundle size 通常也代表他的原始碼會比較簡短好 trace(但不代表實作上比較簡單),用起來負擔也很輕。

Jōtai 是日文的 “狀態” 的意思,最開始是由一個產量極高的日本工程師 - Daishi Kato 所開發,在其部落格上有介紹初始動機與一開始的 prototype - use-atom

現在 Jotai 則是移至 @pmndrs 去維護,其底下還有像是 Zustandvaltio 這類簡化 Redux 與 Mobx 的 state management tool,以及更廣為人知的 react-springreact-three-fiber

Atomic

進入 Jotai 的介紹前,先簡介一下 Atomic 是什麼。

Recoil 中定義 atom 是你 application 中的一小塊狀態,感覺像是把原本 redux state tree 中的狀態都切割成可以獨立創建(可以 on-demand create,不一定要在何時創建)、更新、讀取的個別 state,有助於 code splitting。

每一個 atom 除了 primitive state 外,也能非同步處理 derived state(根據別的 state 進行運算、呼叫 API 等 side effect),加上 atom 是存在 React tree 中,能很簡單得搭配 <Suspense><ErrorBoundary> 來處理 side effect 狀態。

這些個別的 atom 可以隨時被不同 component 給取用與更新,只有與該 atom 有關聯的 component 會在 atom 更新時觸發 re-render,因此相比單純使用 React Context 來說,用在頻繁更新的 application 上也沒問題。

但值得一提的是,Recoil 與 Jotai 底層都還是用了 React Context,只是都用了useMutableSourceuseRef 來 bail out rerendering。

P.S. jotai 原本使用同為 dai-shi 開發的 use-context-selector,但就在一週前左右,改為使用與 Recoil 相同的 useMutableSource solution,猜測是為了能更好的 support concurrent mode 底下的各種使用情境。細節可參考這隻 PR

P.S.S 針對 use-context-selector,可以參考先前文章 - Context API 效能問題 - use-context-selector 解析 了解其實作(文章內容是 v1 的實作,目前已經有 v2 版本)

Recoil vs Jōtai

Jotai 的官方說明這篇文章詳細比較了 Recoil 與 Jotai 的差異,推薦有興趣的讀者去閱讀。

官網從幾個面向來分析差異,並說明了兩者的使用時機,我這邊翻譯總結一下:

  • 開發資源

    Jotai 是由 Poimandres 的幾位開發者共同維護,而 Recoil 除了社群外還有 Facebook 的支援。

  • 功能差異

    Jotai 著重在易學且簡潔的 primitive API,目標是 unopinionated 的 library,功能上不比 Recoil 能支援得多;Recoil 應該是希望能支援多種需求,並應用在大型且有複雜交互作用的應用程式上。

  • 使用技術上的主要差異

    Jotai 的 atom object 沒有 key,用的是 object referential identities,而 Recoil 的 atom 則有 string keys,除了在判斷 atom 更新上會有所不同外,debug 時,Jotai 也需要額外設置 debugLabel,Recoil 則可以直接利用 atom key 來輔助。

    依靠 object referential identities 的另一個潛在問題是,當你用 React Fast Refresh 時,頁面上舊的狀態不能被保留住,因為 refresh 後的 atoms 都會是新的 object。這點在 Recoil 就沒問題,因為他們可以用 string key 來辨別。

  • 使用時機

    1. 如果你只是想替換掉 React Context,避免因頻繁更新造成的效能問題,Jotai 可能是個好選擇,能提供足夠的功能與輕量的 bundle size 和 API。
    2. 如果你的應用程式需要 serialize state,例如從 localStorage 或 server,Recoil 可能有比較多的 utils 可用。
    3. 如果你想在 Jotai 或 Recoil 的基礎上再去開發新的 library,Jotai 的 primitive API 可能比較適合你使用。

    如果上述三點都不是你的 deal-breaker,那選哪個都可以,Jotai 跟 Recoil 在概念與目的上基本是一樣的。

接下來會主要介紹 Jotai 的核心用法。

Jōtai

不過還是先看個最簡單的例子比較有感覺:

import { useAtom, Provider } from 'jotai'

const countAtom = atom(0)
const Counter = () => {
  const [count, setCount] = useAtom(countAtom)
  return (
    <h1>
      {count}
      <button onClick={() => setCount(c => c + 1)}>one up</button>
    </h1>
  )
}

const Root = () => (
  <Provider>
    <Counter />
  </Provider>
)

最簡單的用法就跟 React.useState 一樣,差別只在於我們需要先用 atom() 來創建一個 atom 傳入 useAtom 使用,接下來 useAtom 一樣會回傳一個 tuple,包含目前的值與一個 updating function。

這個例子就展示完了 Jotai 的三個核心函式(jotai/core):

atom

atom 函數用來創建 atom,接受至多兩個參數,當只有第一個參數,且該參數為非函數時,atom() 回傳的是 primitive atom;若是傳入 function,則回傳 derived atom。

const primitiveAtom = atom(initialValue)
const derivedAtomWithRead = atom(readFunction)
const derivedAtomWithReadWrite = atom(readFunction, writeFunction)
const derivedAtomWithWriteOnly = atom(null, writeFunction)

如上面範例所示,derived atom 根據傳入的函數分為 writable atom 或 read-only atom:

  • 若只傳入 readFunction:(get) => value | Promise<value>,則代表為 read-only atom,其中傳入 readFunction 的 get 函數可以用來讀取目前存在 application 中的 atom 的值,此外 get 會追蹤 dependency,意思是,當讀取的 atom 的值變動時,會觸發這個 get 函式,重新計算這個 derived atom 的值。

    舉個例子來說:

    const uppercaseAtom = atom((get) => get(textAtom).toUpperCase())

    在這段程式中,uppercaseAtom 是由傳入一個 readFunction 的 atom 函式所創建的 derived atom。該 readFunction 會讀取 textAtom 的值來做運算並回傳,所以 textAtom 是 uppercaseAtom 的 dependency,當 textAtom 變動時,這個 readFunction 會重跑一遍,讓 uppercaseAtom 也連帶更新。

  • 若有傳入 writeFunction:(get, set, update) => void | Promise<void>,就會回傳 writable atom。其中 get 與 readFunction 的 get 類似,但這個 get 函數不會因為 dependcy 變動而被觸發,比較像是讓你在 update atom 值時,可以拿別的 atom 來操作;set 就是用來更新 application 中的 atom 值;update 則是當外部透過類似 setState 的函式(實際上會是 useAtom 回傳的 updating function)試圖更改這個 derived atom 時會傳入的值,例如:setState(newValue)update 就會是 newValue。

    P.S. primitive atom 是 writable atom,其 writeFunction 就等同於 useState 回傳的 setState()

provider

Provider 就是儲存 atom value 的地方,用法跟 React context provider 一樣:

const Root = () => (
  <Provider>
    <App />
  </Provider>
)

你可以用一個 provider 放在你的 Root component,也可以創好幾個 provider 個別放在不同的 component tree 中,這樣 atom 就會存在各自的 component tree 裡。

useAtom

useAtom 就像是 useState 一樣的 hook,用來讀取 Provider 內的 atom 值,並且會回傳 updating function:

const [value, updateValue] = useAtom(anAtom)

如同最前面範例說的,需要傳入一個 atom,可以是 primitive atom 也可以是 derived atom,如果是 derived atom,他會先執行 readFunction,計算完值以後再回傳。 若是第一次使用該 atom,也就是代表 Provider 內還沒有存任何 value 時,這邊傳入的 atom 就會被作為 initial value,存到 Provider 中。

此外,如同前面 atom 的介紹,當傳入的 atom 變更時,無論是 primitive atom 或是 derived atom,這邊也都會連帶更新。

Jotai 就是透過這種方式在不同的 component 之間共享 state。

至於 useAtom 回傳的 tuple 中的第二個值,也就是 updating function,會依照傳入 atom 的不同有不同的行為,若是 primitive atom,會使用內建的 updating function,模擬 React.setState;若是有傳入自訂 writeFunction 的 writable atom,則會將傳入 updating function 的值傳給 writeFunction 執行。

Async 的使用

接著我們可以來看看怎麼在 Jotai 中使用 async function,像是拿 API 資料或是觸發 action 等等。

derived async read-only atom

在 atom 的 readFunction 中讀取 API 資料:

const urlAtom = atom("https://json.host.com")
const fetchUrlAtom = atom(
  async (get) => {
    const response = await fetch(get(urlAtom))
    return await response.json()
  }
)
function Status() {
  const [json] = useAtom(fetchUrlAtom)
}

假如 urlAtom 被更改,readFunction 會重新執行,然後 Status component 的 re-render 會等到 readFunction 執行完,useAtom 取得新值後才進行。

derived async writable atom

除了 readFunction,我們也能在 writeFunction 中放入 async function:

const fetchCountAtom = atom(
  (get) => get(countAtom),
  async (_get, set, url) => {
    const response = await fetch(url)
    set(countAtom, (await response.json()).count)
  }
)
function Controls() {
  const [count, compute] = useAtom(fetchCountAtom)
  return <button onClick={() => compute("http://count.host.com")}>compute</button>
}

一個實際一點的範例:

Edit hacker_news

const postId = atom(9001)
const postData = atom(async (get) => {
  const id = get(postId)
  const response = await fetch(
    `https://hacker-news.firebaseio.com/v0/item/${id}.json`
  )
  return await response.json()
})
function Id() {
  const [id] = useAtom(postId)
  const props = useSpring({ from: { id: id }, id, reset: true })
  return <a.h1>{props.id.to(Math.round)}</a.h1>
}
function Next() {
  const [, set] = useAtom(postId)
  return (
    <button onClick={() => set((x) => x + 1)}>
      <div></div>
    </button>
  )
}
function PostTitle() {
  const [{ by, title, url, text, time }] = useAtom(postData)
  return (
    <>
      <h2>{by}</h2>
      <h6>{new Date(time * 1000).toLocaleDateString('en-US')}</h6>
      {title && <h4>{title}</h4>}
      <a href={url}>{url}</a>
      {text && <div>{Parser(text)}</div>}
    </>
  )
}
export default function App() {
  return (
    <Provider>
      <Id />
      <div>
        <Suspense fallback={<h2>Loading...</h2>}>
          <PostTitle />
        </Suspense>
      </div>
      <Next />
    </Provider>
  )
}

在這個範例中可以看到要如何在不同的 component 間使用定義在 Global 的 atoms,當 <Next /> 元件的 button 被點選時,觸發了 updating function 來更改 postId atom,同時 postData 這個 derived atom 因為其 readFunction 中有 get postId atom,所以也會被觸發,導致 PostTitle 能夠取得新值,並 re-render component。

caveat

在 Jotai 上使用 async function 時要注意一點,就是必須搭配 React.Suspense,因為當 async function 還沒回傳值時,React tree 會被 suspense 住。

Utils

在上面的範例中,我們的 <Next /> component 只有用到 useAtom 回傳的 updating function,但是每當 postId 更新時,他也會被觸發 re-render。

你可以在原本的 code 中加入 (rendered: {++useRef(0).current}) 來驗證看看

function Next() {
  const [, set] = useAtom(postId)
  return (
    <button onClick={() => set((x) => x + 1)}>
      <div></div>
      (rendered: {++useRef(0).current})
    </button>
  )
}

你會發現每點一次,Next 元件都會被觸發 render,但其實 Next 元件沒有讀取 postId atom 的值,不需要觸發 re-render的。

這問題可以運用 useMemo 把 useAtom 多包一層來解決,如下:

const useSetAtom = (anAtom) => {
  const writeOnlyAtom = useMemo(() => atom(null, (get, set, x) => set(anAtom, x)), [anAtom]);
  return useAtom(writeOnlyAtom)[1];
};

這種感覺就很常需要用到的 hooks,Jotai 有另外寫了一系列的 utils function 供大家使用,放在 jotai/utils 底下。

在官方 github 中,可以找到有哪些 utils,包含使用方法、範例,甚至連促使該 util 產生的 issue,有需要的時候可以去查詢。

結論

到這邊就差不多把基本的用法與概念都介紹完了,以 Atomic 為概念的 state management 在使用上相對簡單,jotai 的精簡 API 也讓入門非常容易,雖然維護人員不多,但主要貢獻者的生產力很強大,也很厲害,我認為在小專案上還是非常適合拿來使用!接下來有機會的話,想從 jotai 的原始碼來了解是如何實作 atomic 概念的 state management library!感謝大家收看!

© by Arvin Huang. All rights reserved.
theme inspired by @mhadaily
Last build: 05-05-2023