React(15) - context vs Jotai


Posted by TempuraEngineer on 2023-03-29

目錄


Jotai是甚麼

Jotai和Redux一樣是用於全域狀態管理的套件

全域狀態管理是指將狀態存在外部的store,因此不會像useState、useContext和組件切不開

故可以用全域性的一次只出現一個的snackbar、dialog上

Jotai有以下幾個特色

  1. bundle size較小

    npm trend

  2. 將一大塊的全域狀態(state tree),切成小塊變成atom

  3. 只有引入某個atom的component會在該atom被更新時re-render降低效能衝擊
  4. 寫法類似useState、Redux,心智負擔較低


Jotai用法

atom

用於存放基本值

interface ClothesDetail {
    color: string;
    material: string;
    type?: string;
    brand?: string;
    price?: number;
}

const clothesDetailAtom = atom<ClothesDetail>({
    color:'black',
    material: 'cutton,
});

使用時

const [clothesDetail, setClothesDetail] = useAtom(clothesDetailAtom);


read-only atom & write-only

read-only atom和write-only atom可以定義成只存取或更新atom的部分屬性

也就是說如果需要的話你甚至可以一個屬性拆出一個atom出來

// read-only
const getClothesDetailAtom = atom((get) =>
  get(clothesAtom),
);

// write-only
const changeClothesDetailAtom = atom(
  null,
  (get, set, update: Partial<ClothesDetail>) => {
    const clothesDetail = get(clothesAtom);

    set(clothesAtom, { ...clothesDetail, ...update });
  }
);

使用時

const [clothesDetail] = useAtom(getClothesDetailAtom);
const [, changeClothesDetail] = useAtom(changeClothesDetailAtom);


select atom

用於存取物件,尤其是物件中包了物件的結構,或者避免陷入infinite loop

接受2個callback,第一個是selector function,第二個是equality function

預設情況下,當參照的atom變動時selector function也會執行,因此可以得到參照的atom的部分current value

如果希望selector function只在reference一樣時執行,可以傳equality function去做比較

而reference一樣是傳址之意,當參照的atom值整個被重新賦值時,就是不一樣的reference

const coordinationAtom = atom({
    top:{
        type:'t-shirt'
        color:'black',
        material:'cotton'
    },
    bottom::{
        type:'gaucho pants'
        color:'white',
        material:'linen'
    }
})

const topCoordinationAtom = selectAtom(coordinationAtom, (coordination) => coordination.top);
const bottomCoordinationAtom = selectAtom(coordinationAtom, ({bottom}) => bottom);

使用時

const [topCoordination] = useAtom(topCoordinationAtom);
const [{color, material, type}] = useAtom(bottomCoordinationAtom);

至於避免infinite loop,是因為select atom可以提供一個穩定的reference,因此就可以避免這個問題


split atom

用於陣列,共有remove、insert、move這幾種類型的事件可以用

const shoppingCartAtom = atom([{
        type:'t-shirt'
        color:'black',
        material:'cotton',
        price:300
    },
    {
        type:'long skirt'
        color:'navy',
        material:'denim',
        price:500
    }
]);

const shoppingCartListAtom = splitAtom(shoppingCartAtom);

使用時

const shoppingCart = () => {
  const [shoppingCartList, dispatch] = useAtom(shoppingCartListAtom);

  return (
    <ul>
      {shoppingCartList.map((item) => (
        <CardItem
          item={item}
          // 類似Redux的dispatch
          remove={() => dispatch({ type: "remove", atom: item })}
        />
      ))}
    </ul>
  );
};


async atom

Jotai支援非同步的讀與寫

傳非同步的callback給atom就能建立一個async atom

用於存取非同步的值(ex:打API撈資料、一些需要使用Promise處理的特殊情境)

// async read-only
const fetchUrlAtom = atom(
  async (get) => {
    const response = await fetch('your API url here')
    return await response.json()
  }
);

使用時

const [apiData] = useAtom(fetchUrlAtom)

(2023/6/13更新)
從v2開始,async atom變成就只是個回傳值為promise的atom,它並不會處理promise
,所以asyn atom的read function需要加上await或者.then()


(2023/6/13更新)

store API

從v2開始移除了JotaiProvider的scope props,並新增了store API的功能

可以把它想像成是Redux的store的概念,store可以傳給jotai Provider

使用createStore建立store

    import { createStore } from 'jotai' // or from 'jotai/vanilla'

    const store = createStore();
    // createStore接收初始值,並回傳store物件
    // 也可以建立React Context並傳給store

store有三個方法,get、set、sub

  • get
    store.get(fooAtom)
    
  • set
    store.set(fooAtom, 1)
    
  • sub
    監聽state的改變
      const unsub = store.sub(fooAtom, () => {
        console.log('fooAtom value in store is changed')
      })
    


getDefaultStore & Provider

jotai Provider組件提供state給component sub tree,且多個Provider是同時獨立存在的

使用Provider有幾個優點

  1. 拆分sub tree的state,避免互相污染
  2. 重新渲染時清空atom

如果某個atom不在Provider下,這就是provider-less mode,這時會使用default的state

default state也可以用getDefaultStore方法取得


情境

假設有3個組件分別如下

└── CreatePhotoWork(上傳作品(照片url、敘述)的頁面)
├── FileUpload(上傳圖片的組件)
└── ImageCrop(切裁圖片的組件)

操作流程則如下

  1. 在FileUpload選擇圖片,觸發input的onChange
  2. 從input取得File
  3. 把File丟給ImageCrop,然後轉成切裁的圖
  4. a. 切裁完按下ImageCrop的OK後,(呼叫setPreview方法)繪製canvas
    b. 將canvas轉成Blob,再轉成File
  5. (這一步在下方例子會省略)
    a. 按下FileUpload的upload,跳出loading
    b. 把從ImageCrop得到的File傳給後端)


使用context實作

context可以讓多個組件共享資料,而不必一層一層地把props傳到最底下

呼叫useContext的組件能夠取得最靠近的Context.Provider的value

然而缺點是,若子組件需要能更新context的值,就必須把set function從父組件傳下去給子組件

如果用以上的情境來說,使用context可以這麼做

  1. 在父組件(頁面組件)建立context,一開始可為空
     import { createContext } from "react";
     export const PhotoStateContext = createContext<PhotoState | null>(null);
    
  2. 在父組件使用useState建立存context值的變數、更新context值的set function

     export type PhotoState = {
       previewSrc: string;
       photoToUpload?: File;
     };
    
     const Context = () => {
       const [photoState, setPhotoState] = useState<PhotoState>({
         previewSrc: "",
         photoToUpload: undefined
       });
    
  3. 在父組件用context建立Context.Provider並把步驟2的變數傳給value
       return (
         <PhotoStateContext.Provider value={photoState}>
            // 省略
        </PhotoStateContext.Provider>
     );
    
  4. a. 在需要能更新context值的子組件開個props,傳入步驟2的set function

     <PhotoStateContext.Provider value={photoState}>
         <FileUpload inputRef={inputRef} setPhotoState={setPhotoState} />
         <ImageCrop
           src={photoState.previewSrc}
           input={inputRef.current}
           setPhotoState={setPhotoState}
         />
     </PhotoStateContext.Provider>
    

    b. 在需要取得context值的子組件使用useContext,並傳入步驟1建立的context

     import { createContext } from "react";
    
     interface ImageCropProps {
        src: string;
        input: HTMLInputElement | null;
        setPhotoState: (data: PhotoState) => void;
     }
    
     const ImageCrop: FunctionComponent<ImageCropProps> = ({
       src,
       input,
       setPhotoState
     }) => {
         const photoState = useContext(PhotoStateContext);
    

codesandbox


使用Jotai實作

如果用以上的情境來說,使用Jotai可以這麼做

  1. 開一個存放atom的檔案(ex: store.ts),並使用atom建立一個存放state的atom

     import { atom } from "jotai";
    
     interface PhotoState {
         photoToUpload?: File;
         previewSrc: string;
     }
    
     export const photoStateAtom = atom<PhotoState>({
         previewSrc: ""
     });
    
  2. 在store.ts視需求撰寫write或read atom

     // 這是write-only atom
     // getter為null,setter為function,所以只能更新
     export const changePhotoStateAtom = atom(
       null,
       (get, set, update: Partial<PhotoState>) => {
         const photoState = get(photoStateAtom);
    
         set(photoStateAtom, { ...photoState, ...update });
       }
     );
    
  3. 在需要取得、更新atom值的地方引入atom

    之後的用法就類似useState

     import { useAtom } from "jotai";
     import { photoStateAtom } from "../../store";
    
     const Jotai = () => {
       // 第一個元素是atom值,第二個是set function
       const [{ previewSrc, photoToUpload }] = useAtom(photoStateAtom);
    
     import { useAtom } from "jotai";
     import { changePhotoStateAtom } from "../../store";
    
     interface ImageCropProps {
       src: string;
       input: HTMLInputElement | null;
     }
    
     const ImageCrop: FunctionComponent<ImageCropProps> = ({ src, input }) => {
       // write-only atom
       // 第一個元素為null,第二個為set function
       const [, setPhotoState] = useAtom(changePhotoStateAtom);
    

codesandbox


參考資料

Jotai
Jōtai 介紹
Jotai - Provider
Jotai - v2 API migration


#React #useContext #jotai #useAtom #createStore







Related Posts

簡明約耳趣談軟體(Joel on Software)導讀書摘

簡明約耳趣談軟體(Joel on Software)導讀書摘

曼陀號領航計畫(2) Climb the ladder like Spider-Man

曼陀號領航計畫(2) Climb the ladder like Spider-Man

Wampserver如何安裝舊版本php

Wampserver如何安裝舊版本php


Comments