Skip to main content

[note] Recoil

此篇為各筆記之整理,非原創內容,資料來源可見下方連結與文後參考資料:

概念

Atoms

Atoms Basic

keywords: useRecoilState

Atoms 是 state 的最小單位,它們是可被更新和訂閱的:只要 atom 被更新時,所有有訂閱該 atom 的元件都會 re-rendered 並取得最新的值。Atoms 可以用來取代 React 元件中的 local state。當有多個元件共用相同的 atom 時,這些 components 都可以共享 state,並取得相同的 state

只要有使用到某 atom 的元件,等同於該元件會自動訂閱該 atom,一旦該 atom 有任何更新,都會導致該元件 re-render。

const darkModeState = atom<boolean>({
key: 'darkMode',
default: false,
})

const Foo = () => {
const [darkMode, setDarkMode] = useRecoilState(darkModeState);
return (
/* ... */
)
}

Selectors

keywords: useRecoilValue

Selector Basics

selectors 是一個可以接收 atoms 或其他 selectors 做為參數的 pure function, 它會根據 state 來計算取的新的值。我們會盡可能讓 state 保持乾淨而不保存其他多餘的狀態,如果有些資料是需要根據這些 state 計算產生的時候,則可以使用 selector。

當有新的 atoms 或 selectors 更新時,selector function 就會重新被執行,並取得最新的 state。如同 atoms 一樣,React 元件也可以訂閱 selectors,當 selectors 改變時,這些元件也會 re-rendered。

selectors 會追蹤哪些元件會用到它們,以及它們相依於哪些 state,因此可以很有效率的去執行這個函式。

note

selectors 給我的感覺很像 Vue 中的 computed。簡單來說,如果有需要更新 atom 中的資料,則使用 useRecoilState,如果只需根據 state 取得狀態的話,就使用 selector 提供的 useRecoilValue

const usdAtom = atom<number>({
key: 'usd',
default: 1,
})

const eurSelector = selector<number>({
key: 'eur',
get: ({get}) => {
let usd = get(usdAtom)
return usd * exchangeRate
},
set: ({set, get}, newEurValue) => {
// @ts-expect-error
const newUsdValue = newEurValue / exchangeRate
set(usdAtom, newUsdValue)
},
})

const Foo = () => {
const [usd, setUsd] = useRecoilState(usdAtom)
const [eur, setEur] = useRecoilState(eurSelector)

return (
/* ... */
)
}

Atom Family

keywords: atomFamily
function atomFamily<T, Parameter>({
key: string,

default:
| RecoilValue<T>
| Promise<T>
| T
| (Parameter => T | RecoilValue<T> | Promise<T>),

effects_UNSTABLE?:
| $ReadOnlyArray<AtomEffect<T>>
| (P => $ReadOnlyArray<AtomEffect<T>>),

dangerouslyAllowMutability?: boolean,
}): Parameter => RecoilState<T>
caution

呼叫 atomFamily 函式時,帶入的參數(Parameter)一定要是可以被序列化的。

AtomFamily

透過 Atom Family 可以快速建立多個「長相相同」的 Atoms:

  • atomFamily<T, U>:T 指的是每個 atom 的型別, U 指的是用來辨別每個 Atom 的型別(即,每個 Atom 的 id)
  • useRecoilState(elementStateFamily(<id>)):這裡的 id 會是唯一不會重複的,每一個不同的 id 都表示的是 Atom Family 中不同的 Atom
export type ElementStyle = {
position: {top: number; left: number}
size: {width: number; height: number}
}

export type Element = {style: ElementStyle}

export const elementStateFamily = atomFamily<Element, number>({
key: 'element',
default: {
style: {
position: {top: 0, left: 0},
size: {width: 50, height: 50},
},
},
})

export const Rectangle = ({id}: {id: number}) => {
const [selectedElement, setSelectedElement] = useRecoilState(selectedElementState)
// 指定 atomFamily 的方式就是把該 atom 的 id 帶進去
const [element, setElement] = useRecoilState(elementStateFamily(id))
return (
/* ... */
)
}

同樣的在 selector 中也能透過 atomFamily 的 id 取得該 atom:

const selectorElementPropertiesSelector = selector({
key: 'selectorElementProperties',
get: ({ get }) => {
// 這裡帶入 id 即可取得 AtomFamily 內的 Atom
const element = get(elementStateFamily(<id>))
}
})

Selector Family

Selector Families - implement 'top' with SelectorFamily

  • 當 selector 中需要帶入參數,並根據參數產生不同的 selector 時,就可以想到要用 selectorFamily
function selectorFamily<T, Parameter>({
key: string,

get: Parameter => ({get: GetRecoilValue}) => Promise<T> | RecoilValue<T> | T,

set: Parameter => (
{
get: GetRecoilValue,
set: SetRecoilValue,
reset: ResetRecoilValue,
},
newValue: T | DefaultValue,
) => void,

dangerouslyAllowMutability?: boolean,
}): Parameter => RecoilState<T>
caution

selectorFamily 中的 Parameter 一定要是可以被序列化的(serializable),因為該 Parameter 可以是物件,recoil 為了要比較這兩個物件是否完全相同,會以 value 而非 reference 的方式來進行比較。

  • Selector Family 的概念和 Atom Family 的概念類似,在 Atom Family 中,可以針對相同型別的 state 產生多個不同的 Atoms;Selector Family 則是可以針對相似邏輯的 selector,根據不同的參數(例如,下面的 path)來進行些微不同的操作。

Selector Family: A selectorFamily is a powerful pattern that can create similar selectors that behave slightly differently depending on the parameter (from learn recoil).

  • selectorFamily<T, P>T 是回傳的型別,P 是用來判斷是哪個 selector(i.e., key, id)的型別
import {selectorFamily, useRecoilValue} from 'recoil'

const editPropertyState = selectorFamily<number, string>({
key: 'editProperty',
get:
(path) =>
({get}) => {
return 10;
},
set:
(path) =>
({get, set}, newValue) => {
const selectedElementId = get(selectedElementState)
if (selectedElementId === null) return

const element = get(elementStateFamily(selectedElementId))

// prevent lodash mutate the element object
const newElement = produce(element, (draft) => {
_set(draft, path, newValue)
})

// set value to elementStateFamily
set(elementStateFamily(selectedElementId), newElement)
},
})

const Foo = () => {
const property = useRecoilValue(editPropertyState('some-id'))
return (/* ... */)
}

Fetching Data

預設的情況下,Recoil 在處理 async 的情況下,會使用 React 的 Suspense,因此開發者不需要額外透過 loading state 來檢視資料載入的狀態。

但也因此需要把在 recoil 中有處理到 async 的 React 元件包在 <Suspense /> 中。

加上 Suspense

import { Suspense } from 'react';

const Foo = () => {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<SomeComponentHaveWait />
</Suspense>
</div>
);
};

在 selector 中呼叫 API:atom + selector

Data Fetching Basic - fetching data in selector

import { atom, selector, useRecoilState, useRecoilValue } from 'recoil';

// 1. 這個狀態是給使用者切換用的
const userIdState = atom<number | undefined>({
key: 'userId',
default: undefined,
});

// 2. 透過 selector 來 fetch data
const userState = selector({
key: 'user',
get: async ({ get }) => {
const userId = get(userIdState);

if (userId === undefined) return;

const res = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
const userData = await res.json();
return userData;
},
});

export const Async = () => {
const [userId, setUserId] = useRecoilState(userIdState);
const user = useRecoilValue(userState);

return (
// 3. onchange 時會改變 userId,同時觸發 selector 去拉取資料
<Select
placeholder="Choose a user"
mb={4}
value={userId}
onChange={(event) => {
const value = event.target.value;
setUserId(value ? parseInt(value) : undefined);
}}
>
<option value="1">User 1</option>
<option value="2">User 2</option>
<option value="3">User 3</option>
</Select>
);
};

在 selector 中呼叫 API:selectorFamily

Data Fetching Basic - replace dependencies of Atom with React State by selectorFamily

除了像上面一樣讓 selector 相依於 Atom 的 state 之外,也可以讓 selector 是相依於 React 元件本身的 state,這時候因為會需要把參數帶入到 selector 中,因此會用到 selectorFamily

diff --git a/src/examples/Async.tsx b/src/examples/Async.tsx
-const userIdState = atom<number | undefined>({
- key: 'userId',
- default: undefined,
-})
-
-const userState = selector({
+const userState = selectorFamily({
key: 'user',
- get: async ({get}) => {
- const userId = get(userIdState)
- console.log({
- userId,
- })
- if (userId === undefined) return
-
+ get: (userId: number) => async () => {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
const userData = await res.json()
return userData
},
})

-const UserData = () => {
- const user = useRecoilValue(userState)
-
- if (!user) return null
+const UserData = ({userId}: {userId: number}) => {
+ const user = useRecoilValue(userState(userId))

return (
<div>
@@ -44,7 +31,7 @@ const UserData = () => {
}

export const Async = () => {
- const [userId, setUserId] = useRecoilState(userIdState)
+ const [userId, setUserId] = useState<number>()

return (
<Container py={10}>
@@ -67,9 +54,11 @@ export const Async = () => {
<option value="2">User 2</option>
<option value="3">User 3</option>
</Select>
- <Suspense fallback={<div>Loading...</div>}>
- <UserData />
- </Suspense>
+ {userId !== undefined && (
+ <Suspense fallback={<div>Loading...</div>}>
+ <UserData userId={userId} />
+ </Suspense>
+ )}
</Container>
)
}

相依的 selector

Data Fetching Advanced - selector of WeatherState depends on selector of UserState

  • 當兩個會呼叫 async function 的 selector 彼此相依時,recoil 會等到前一個 selector 的值已經 resolve 後才接著執行下一個 selector。以下面的例子來說:
    • weatherState 會相依於 userState,但 recoil 會等 userState 的結果 resolve 之後,才接著執行 weatherState 這個 selector 的 get function
  • Suspense 會等到該元件內的所有資料都 resolve 後才解開
 import {Suspense, useState} from 'react'
import {selectorFamily, useRecoilValue} from 'recoil'
+import {getWeather} from './fakeAPI'

const userState = selectorFamily({
key: 'user',
@@ -12,8 +13,20 @@ const userState = selectorFamily({
},
})

+const weatherState = selectorFamily({
+ key: 'weather',
+ get:
+ (userId: number) =>
+ async ({get}) => {
+ // 這裡並不需要加上 await,recoil 會在取得 userState 後才接著執行這裡的 get function
+ const user = get(userState(userId))
+
+ const weather = await getWeather(user.address.city)
+ return weather
+ },
+})
+
const UserData = ({userId}: {userId: number}) => {
const user = useRecoilValue(userState(userId))
+ const weather = useRecoilValue(weatherState(userId))

return (
<div>
@@ -26,6 +39,11 @@ const UserData = ({userId}: {userId: number}) => {
<Text>
<b>Phone:</b> {user.phone}
</Text>
+ <Text>
+ <b>
+ Weather for {user.address.city}: {weather}°C
+ </b>
+ </Text>
</div>
)
}
@@ -55,6 +73,7 @@ export const Async = () => {
<option value="3">User 3</option>
</Select>
{userId !== undefined && (
+ // Suspense 會在 UserData 中的所有 data 都 resolve 後才解開
<Suspense fallback={<div>Loading...</div>}>
<UserData userId={userId} />
</Suspense>

更新資料:refresh

Data Fetching Advanced - create requestWeatherId to make weatherState refresh

由於 selector 有一個概念是當 input 相同時,會直接把上次 cache 的結果拿出來用,而不會再次執行 selector 內的 get function,這樣使用者只有在第一次請求資料時需要等待,後續都不用在重新 fetch data。

但如果這個資料是有時效性的,需要一直更新的話該怎麼辦呢?其中一種方式是建立額外的一個 id,透過改變這個 id,來讓 selector 內的資料有改變,以觸發 selector 中的 get function 重新執行。

danger

由於 selector 的結果會被 cache,因此當你預期這個資料是會隨著時間改變時,並不適合使用單一個 selector,因為 recoil 會略過再去 fetch 一次最新的資料,並直接回傳前一次的結果(cache)給你。

舉例來說:

  • 額外建立一個狀態是 weatherRequestId
  • weatherState 相依於 weatherRequestId
  • 如此,一旦 weatherRequestId 改變時,recoil 才會重新執行 weatherState 這個 selector 中的 get function,並且重新拉資料
 import {Container, Heading, Text} from '@chakra-ui/layout'
import {Select} from '@chakra-ui/select'
import {Suspense, useState} from 'react'
+import {atomFamily, selectorFamily, useRecoilValue, useSetRecoilState} from 'recoil'
import {getWeather} from './fakeAPI'

const userState = selectorFamily({
@@ -13,11 +13,24 @@ const userState = selectorFamily({
},
})

+// 1. 建立 weatherRequestId
+const weatherRequestIdState = atomFamily({
+ key: 'weatherRequestId',
+ default: 0,
+})
+const useRefetchWeather = (userId: number) => {
+ const setRequestId = useSetRecoilState(weatherRequestIdState(userId))
+ return () => setRequestId((id) => id + 1)
+}
+
const weatherState = selectorFamily({
key: 'weather',
get:
(userId: number) =>
async ({get}) => {
+ // 3. 由於 weatherState 相依於 weatherRequestId,因此一旦此值改變,recoil 就會再次執行此 selector 中的 get function
+ get(weatherRequestIdState(userId))
+
const user = get(userState(userId)) // 這裡並不需要加上 await,recoil 會在取得 userState 後才接著執行這裡的 get function
const weather = await getWeather(user.address.city)
return weather
@@ -28,10 +41,16 @@ const WeatherDate = ({userId}: {userId: number}) => {
const user = useRecoilValue(userState(userId))
const weather = useRecoilValue(weatherState(userId))

+ // 2. 使用者呼叫此 function 時,weatherRequestId 會改變
+ const refetch = useRefetchWeather(userId)
+
return (
- <Text>
- <b>Weather for {user.address.city}:</b> {weather}°C
- </Text>
+ <div>
+ <Text>
+ <b>Weather for {user.address.city}:</b> {weather}°C
+ </Text>
+ <Text onClick={refetch}>(refresh weather)</Text>
+ </div>
)
}

重要概念:Idempotence

selector 回傳的結果會 cache,如果是相同 input 會直接回傳相同結果

在 selector 中有一個很重要的概念是,當代入同樣的 input 時,回傳的結果會被快取起來,因此當下次有同樣的 input 進來時,selector 會直接 return 相同的結果,而不會再跑一次 selector 內的 get 函式

因此以這個例子來說,同樣的 input 代入時,就不會再去 fetch 一次資料,因此也不會再顯示 loading 的狀態:

recoil selector

資料來源:learn recoil

重要概念:recoil 會幫忙處理 race condition

如果使用者先選擇 A,請求 A 的 request 會發出,在 server 還沒傳資料回來前,使用者換成選擇 B。這時如果 B 的結果比 A 先回來,在有 race condition 的情況下,使用者會在畫面上先看到 B 的結果,待 A 的資料回來後,A 的結果會蓋掉 B 的結果(但使用者目前實際上選擇要看的是 B)。

recoil race condition

recoil 則會幫我們處理掉 race condition 這樣的情況。

Error Handling

Error Handling with React Error Boundary

透過 React Error Boundary 可以處理 Suspense 內發生的錯誤。這裡會搭配 react-error-boundary 這個套件。

npm install react-error-boundary

使用 react-error-boundary:

import { ErrorBoundary, FallbackProps } from 'react-error-boundary';

// 建立 FallbackComponent,可以取得錯誤訊息(error)
// resetErrorBoundary 可以用來重設 ErrorBoundary 讓它消失
const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
return (
<div>
<h1>Something went wrong</h1>
<p>{error.message}</p>
<button onClick={resetErrorBoundary}>OK</button>
</div>
);
};

const App = () => (
// FallbackComponent 代入 fallback component
// onReset 是當 resetErrorBoundary 被呼叫時會被執行的方法
// resetKeys 內的值一改變 ErrorBoundary 就會重設
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => setUserId(undefined)}
resetKeys={[userId]}
>
<Suspense fallback={<div>Loading...</div>}>
<UserData userId={userId} />
</Suspense>
</ErrorBoundary>
);

Intermediate Selector

Intermediate Selectors - implement intermediate selector to prevent API keep fetching

Selector 的 getter function 會不會執行取決於 getter 當中相依的變數有沒有改變,如果這個相依的變數是物件的話,將會導致 selector 的 getter 不斷重新被呼叫,如果會在這個 selector 中呼叫 API 的話,便會導致這個 API 一直被呼叫,這時候就會需要 intermediate selector。

這個 intermediate selector 的作用就是讓原本的 selector 不要相依於物件,而是相依於一個值:

  • 原本 imageInfoState 會相依於 get(elementStateFamily(elementId)) 這個物件
  • 建立 imageIdState 這個 intermediate selector,把值從物件中取出 element.image?.id
  • 最後 imageInfoState 是相依於 imageIdState 的值而不是原本的物件
/**
* imageIdState 是 intermediate selector,目的是讓 imageInfoState 相依到的是 imageIdState
*/
const imageIdState = selector({
key: 'imageId',
get: ({ get }) => {
const elementId = get(selectedElementState);
if (elementId === null) return;

const element = get(elementStateFamily(elementId));
return element.image?.id;
},
});

// imageInfoState 會在 imageIdState 回傳的值「有改變時」才會觸發
const imageInfoState = selector({
key: 'imageInfo',
get: ({ get }) => {
const imageId = get(imageIdState);
if (imageId === undefined) return;

return callApi<IImageInfo>('image-details', { queryParams: { seed: imageId } });
},
});

Useful Pattern

這是根據 Context 常用的 Pattern 在 recoil 上加以實作,可以參考這份 gist 的步驟。