Skip to main content

[note] Recoil

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

概念#

Atoms#

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#

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 中需要帶入參數,並根據參數產生不同的 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#

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#

除了像上面一樣讓 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#

  • 當兩個會呼叫 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#

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

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

舉例來說:

  • 額外建立一個狀態是 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 這樣的情況。

Last updated on