# Jotai

Jotai (opens new window)Poimandres (opens new window) 团队开发的第三个状态管理库。Zustand (opens new window)Valtio (opens new window) 以数据为中心,而 Jotai 以 React 组件为中心。React 通过很多小组件构建大的应用程序,同样 Jotai 通过很多小的原子状态构建整个应用状态。Jotai 的核心 API 很简单,就像在使用 useState 一样,但是 Jotai 提供了大量的工具和扩展来增强 Jotai 的能力。

Zustand、Valtio 和 Jotai 我们该使用哪个呢?请参考作者的文章 When I Use Valtio and When I Use Jotai (opens new window)Can valtio replace jotai completely? (opens new window)

# 安装

$ pnpm add jotai
1

# 基础用法

Jotai 的核心 API 很简单,首先通过 atom 函数定义一个原子 atom。

Jotai 建议使用 Primitive 类型的数据,如果使用对象类型的数据,需要注意,避免多余的渲染。

// countAtom.ts
const countAtom = atom(0);
export default countAtom;
1
2
3

然后通过 useAtom hook 使用这个 atom

// BearComponent.tsx
import countAtom from '@store/countAtom';
import { useAtom } from 'jotai';

function BearComponent() {
  const [count, setCount] = useAtom(countAtom);
	const increase = () => setCount((s) => s + 1);
  const reset = () => setCount(0);

  return (
    <div>
      <div>Bears: {count}</div>
      <div>
        <Button onClick={increase}>增加</Button>
        <Button onClick={reset}>重置</Button>
      </div>
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

useAtom 就像 useState 一样,返回了一个值和更新函数。

除了使用 useAtom 之外,Jotai 还提供了两个 API:useAtomValueuseSetAtom

// 只读取 atom 的值
const count = useAtomValue(countAtom)

// 只获取更新函数。
// 如果组件不需要获取值,通过这个 API 可以避免因 atom 变化而导致的无意义的更新。
const setCount = useSetAtom(countAtom)
1
2
3
4
5
6

# StoreProvider

在 Jotai 里 atom 函数只是创建了一个原子配置,并没有保存一个值,真实的值其实是存在 store 里的。我们可以通过 createStore 函数创建一个 store。通过 store 可以获取、设置 atom 的值,而且能订阅 atom 的变化。

const countAtom = atom(0);

// 创建 store
const myStore = createStore();

// 设置 atom 的值
myStore.set(countAtom, 1)

// 订阅 atom 的变化
// 调用 `unsub()` 取消订阅
const unsub = myStore.sub(countAtom, () => {
  // 获取 atom 的值
  const value = myStore.get(countAtom)
  console.log('countAtom value is changed to', value)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Provider 组件会创建一个 store(或者通过 store prop 参数传入), 从而为不同的 React 子树提供不同的 atom 值,它们可以嵌套使用,像 React Context 一样。当 Provider 组件卸载时,它将清除 store 里面所有的 atom 值。








 






















 




 
 
 
 






import { useAtom, useStore } from 'jotai';
import countAtom from '@store/countAtom';

function BearComponent() {
  const [count, setCount] = useAtom(countAtom);
  const increase = () => setCount((s) => s + 1);
  const reset = () => setCount(0);
  const myStore = useStore();

  useEffect(() => {
    const unsub = myStore.sub(countAtom, () => {
      console.log(
        'countAtom value is changed to',
        myStore.get(countAtom),
      );
    });
    return () => unsub();
  }, []);

  return (
    <div>
      <div>Bears: {count}</div>
      <div>
        <Button onClick={increase}>增加</Button>
        <Button onClick={reset}>重置</Button>
      </div>
    </div>
  );
}

const store = createStore();

const StorePage = () => {
  return (
    <div>
      <Provider store={store}>
        <BearComponent />
      </Provider>
      <Provider>
        <BearComponent />
      </Provider>
    </div>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

上面例子中,两个 BearComponent 在不同的 Provider React 子树下,所以它们的 countAtom 状态值是不同的,而且相互独立。

在组件里可以通过 useStore hook 获取当前 React 子树的 store。

如果 React 子树没有提供 Provider,这种模式称为 "provider-less",它会提供一个默认的 store,可以通过 getDefaultStore 函数获取这个默认的 store

const defaultStore = getDefaultStore();
1

# 派生

atom 除了定义一个原子 atom 之外,还可以创建派生 atom

// deriveStore.ts
import { atom } from 'jotai';

export const textState = atom('hello');

export const uppercaseTextState = atom(
  (get) => get(textState).toUpperCase(),
  (get, set, update: string) => set(textState, update.toLowerCase()),
);
1
2
3
4
5
6
7
8
9

Jotai 也可以定义只读或者只写的 atom,只要相应的 read 或者 write 参数传入 null 或者不写即可。

import { textState, uppercaseTextState } from '@store/deriveStore';
import { useAtom, useAtomValue } from 'jotai';

const DerivePage = () => {
  const [text, setText] = useAtom(textState);
  const uppercaseText = useAtomValue(uppercaseTextState);

  return (
    <div className={styles.container}>
      <Input
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <div>{uppercaseText}</div>
      </div>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

通过派生可以很容易实现 atom 的计算属性。

# Actions

Jotai 不支持在 atom 里定义 action,但是可以通过自定义 hook 来定义 actions.

export const useFishState = () => {
  const [count, setCount] = useAtom(fishCountAtom);

  const increase = () => {
    setCount((s) => s + 1);
  };

  const reset = () => {
    setCount(0);
  };

  return [count, increase, reset] as const;
};
1
2
3
4
5
6
7
8
9
10
11
12
13

# 异步

Jotai 异步和同步没有区别,定义一个异步 action,然后在 action 里直接调用 setState 即可。Jotai 也支持在派生里定义异步操作,但是使用异步派生 atom 时需要搭配 SuspenseErrorBoundary 组件使用,这个类似于 Valtio 的 Promise.

// promiseStore.ts
import { atom } from 'jotai'

export const userIdAtom = atom(1)
export const userAtom = atom(async (get, { signal }) => {
  const userId = get(userIdAtom)
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${userId}?_delay=2000`,
    { signal },
  )
  return response.json()
})
1
2
3
4
5
6
7
8
9
10
11
12

Jotai 的 read 函数支持 signal 参数,它使用了 AbortController (opens new window),这样你就可以中止异步函数。Jotai 会在在重新计算(即调用 read 函数)时中止异步函数。

import { userAtom, userIdAtom } from '@store/promiseStore';
import { useAtom } from 'jotai';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

const Controls = () => {
  const [userId, setUserId] = useAtom(userIdAtom);
  return (
    <div>
      <span>User Id: {userId}</span>
      <Button onClick={() => setUserId((c) => c - 1)}>Prev</Button>
      <Button onClick={() => setUserId((c) => c + 1)}>Next</Button>
    </div>
  );
};

const UserName = () => {
  const [user] = useAtom(userAtom);
  return <div>User name: {user.name}</div>;
};

const AsyncPage = () => {
  return (
    <div className={styles.container}>
      <Controls />
      <ErrorBoundary fallback={<div>出错了</div>}>
        <Suspense fallback="Loading...">
          <UserName />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

# loadable

如果不想 React 处理回退状态和错误,可以使用 loadable 工具函数。loadable 处理异步状态,返回下面的数据结构:

{
  // 3 种状态
  state: 'loading' | 'hasData' | 'hasError', 
  data?: any,
  error?: unknown
}
1
2
3
4
5
6
import { loadable } from "jotai/utils"

const loadableAtom = loadable(userAtom)

const UserName = () => {
  const [value] = useAtom(loadableAtom);
  if (value.state === 'hasError') return <div>出错了</div>;
  if (value.state === 'loading') return <div>Loading...</div>;
  return <div> User name: {value.data.name}</div>;
};

const LoadablePage = () => {
  return (
    <div>
      <UserName />
      <Controls />
    </div>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 持久化

Jotai 提供 atomWithStorage 工具函数来实现 Storage.

atomWithStorage 创建一个 atom,其值保存在 localStorage/sessionStorage 或者 React Native 的 AsyncStorage 中。

import { useAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
const fishCountAtom = atomWithStorage('fish-count', 0);

export default fishCountAtom;
1
2
3
4
5

atomWithStorage 有四个参数

  • key:Storage key
  • initialValue: 初始值
  • storagelocalStorage(默认值)、sessionStorageAsyncStorage,也可以自定义 storage
  • options
    • getOnInit:一个布尔值,指示是否在初始化时从存储器中获取项,默认是 false

# 删除存储

通过 set(RESET) 可以删除存储

import { useAtom } from 'jotai'
import { atomWithStorage, RESET } from 'jotai/utils'

const textAtom = atomWithStorage('text', 'hello')

const TextBox = () => {
  const [text, setText] = useAtom(textAtom)

  return (
    <div>
      <Input value={text} onChange={(e) => setText(e.target.value)} />
      <Button onClick={() => setText(RESET)}>Reset (to 'hello')</Button>
    </div>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 重置

如果想要把 atom 重置到初始值,可以使用 atomWithReset 工具函数

import { atomWithReset } from 'jotai/utils'
const dollarsAtom = atomWithReset(0)
1
2

通过 set(RESET) 或者 useResetAtom 函数重置值到初始值

import { useSetAtom } from 'jotai'
import { RESET, useResetAtom } from 'jotai/utils'

const ResettablePage = () => {
  const setDollars = useSetAtom(dollarsAtom)
  const resetDollars = useResetAtom(dollarsAtom)

  return (
    <div>
      <Button onClick={() => setDollars(RESET)}>Reset dollars</Button>
      <Button onClick={resetCents}>Reset cents</Button>
    </div>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

除了 atomWithReset 工具函数,还有 atomWithDefault 工具函数,它的初始值通过一个 read 函数来指定,而不是一个静态的初始值

import { atomWithDefault } from 'jotai/utils'

const count1Atom = atom(1)
const count2Atom = atomWithDefault((get) => get(count1Atom) * 2)
1
2
3
4

atomWithDefault 的 atom 通过 set 函数修改之后,它与原来依赖的 atom 之间的联系就被切断了,修改原来依赖的 atom 不会再改变 atomWithDefault 的 atom,通过 useResetAtom 重置,将重新建立彼此之间依赖。

此外还有一个 atomWithRefresh 工具函数,用来重新获取数据

import { atomWithRefresh } from 'jotai/utils'

const postsAtom = atomWithRefresh((get) =>
  fetch('https://jsonplaceholder.typicode.com/posts').then((r) => r.json()),
)

const PostsList = () => {
  const [posts, refreshPosts] = useAtom(postsAtom)

  return (
    <div>
      <ul>
        {posts.map((post) => (<li key={post.id}>{post.title}</li>))}
      </ul>
      <Button onClick={refreshPosts}>Refresh posts</Button>
    </div>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 选择

前面提到过 Jotai 建议使用 Primitive 类型的数据,如果使用对象类型的数据,需要注意,避免多余的渲染。比如下面的例子

import { atom, useAtom, useSetAtom } from 'jotai';

const userAtom = atom({
  id: 3,
  name: 'jotai',
  age: 18,
});

const UserNameComponent = () => {
  const [name, setName] = useSetAtom(userAtom);
  const changeName = () => {
    setName((prev) => ({
      ...prev,
      name: 'jotai',
    }));
  };

  return (
    <div>
      <div>Name: {name}</div>
      <div>
        <Button onClick={changeName}>修改 Name</Button>
      </div>
    </div>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

即使 name 没有变化,但是因为整个对象变了,也会导致组件重新渲染。

对于这种情况,可以使用 selectAtom 工具函数



 


 


















import { selectAtom } from 'jotai/utils';

const selectNameAtom = selectAtom(userAtom, (s) => s.name);

const UserNameComponent = () => {
  const [name] = useAtom(selectNameAtom);
  const setName = useSetAtom(userAtom);
  const changeName = () => {
    setName((prev) => ({
      ...prev,
      name: 'jotai' + '#' + Math.floor(Math.random() * 100),
    }));
  };

  return (
    <div>
      <div>Name: {name}</div>
      <div>
        <Button onClick={changeName}>修改 Name</Button>
      </div>
    </div>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

需要注意的是 selectAtom 返回的是只读 atom,修改还是需要使用原来的 atom。

修改对象 atom 可以使用下面提到的 jotai-immer 扩展。

# 扩展

下面是 Jotai 提供的扩展,每一个都需要安装相应的依赖包。

Jotai 提供了很多扩展,这里只列举几个我平常用到的。更多扩展请参考 Jotai Extensions (opens new window).

# Immer

jotai-immer 扩展简化修改对象 atom,就像 Immer 修改 JS 对象一样。

首先需要安装 immerjotai-immer

$ pnpm add immer jotai-immer
1

jotai-immer 扩展提供了 4 个 API:atomWithImmerwithImmeruseImmerAtomuseSetImmerAtom 。个人觉得 useSetImmerAtom 最实用,不需要定义额外的 atom,也不会导致组件多余的。

比如上面的例子可以通过 useImmerAtom 进行简化

 





 
 
 












import { useSetImmerAtom } from 'jotai-immer';

const UserNameComponent = () => {
  const [name] = useAtom(selectNameAtom);
  const setName = useSetImmerAtom(userAtom);
  const changeName = () => {
    setName((draft) => {
      draft.name = 'jotai' + '#' + Math.floor(Math.random() * 100);
    });
  };

  return (
    <div>
      <div>Name: {name}</div>
      <div>
        <Button onClick={changeName}>修改 Name</Button>
      </div>
    </div>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# Cache

jotai-cache 扩展用于缓存一起的 atom 值,对于网络请求的数据非常有用

首先安装 jotai-cache

$ pnpm add jotai-cache
1

atomWithCache 创建一个带缓存的只读 atom

import { atom, useAtom } from 'jotai'
import { atomWithCache } from 'jotai-cache'

const idAtom = atom(1)
const cachedAtom = atomWithCache(async (get) => {
  const id = get(idAtom)
  const response = await fetch(`https://reqres.in/api/users/${id}?delay=1`)
  return response.json()
})
1
2
3
4
5
6
7
8
9

cachedAtom 会缓存请求的网络数据,使得切换 id 时,直接使用缓存数据,而不需要再次请求数据。

# 调试

Jotai 提供了 Devtools 工具和 Babel 插件来调试 Jotai

首先安装 Devtools 工具

$ pnpm add jotai-devtools
1

然后配置 Babel 预设

{
  "presets": ["jotai/babel/preset"]
}
1
2
3

jotai/babel/preset 包含了两个插件

  • jotai/babel/plugin-react-refresh: 为 atom 添加 React Refresh 支持,确保在使用 React Refresh 开发时状态不会重置。React Refresh 类似于热重载(Hot Reloading),但刷新更可靠,并且得到了 React 的官方支持。
  • jotai/babel/plugin-debug-label: 自动给 atom 添加 debugLabel 属性值,用于调试时标识 atom。

最后添加 DevTools 到 Layout 里

// app.tsx
import { DevTools } from 'jotai-devtools'
import 'jotai-devtools/styles.css';

const App = () => {
  return (
    <>
      <DevTools />
      {/* your app */}
    </>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12

或者在 Provider 里添加

import { createStore } from 'jotai'
import { DevTools } from 'jotai-devtools'
import 'jotai-devtools/styles.css'

const customStore = createStore()

const App = () => {
  return (
    <Provider store={customStore}>
      <DevTools store={customStore} />
      {/* your app */}
    </Provider>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

DevTools 的参数请参考 Available Props (opens new window)

DevTools 支持两个功能:

  • Atom Viewer: 查看 atom 当前值
  • Time travel:浏览 atom 的变化历史

# Hooks

Jotai 除了提供了 Devtools 工具之外,还提供了几个 hooks 用于调试

# useAtomsDebugValue

useAtomsDebugValue 在 React Devtools 中显示所有的 atom 值

import { useAtomsDebugValue } from 'jotai-devtools/utils'
const DebugAtoms = () => {
  useAtomsDebugValue()
  return null
}
const App = () => (
  <Provider>
    <DebugAtoms />
    <TextBox />
  </Provider>
)
1
2
3
4
5
6
7
8
9
10
11

下面的 hooks 需要搭配 ReduxDevTools (opens new window) 使用

# useAtomDevtools

useAtomDevtools 用于管理特定 atom 的 ReduxDevTools 扩展。

import bearCountAtom from '@store/bearStore';
import { useAtomDevtools } from 'jotai-devtools/utils';
function FirstBear() {
  const [count, setCount] = useAtom(bearCountAtom);
  useAtomDevtools(bearCountAtom, { name: 'FirstBear' });
}
1
2
3
4
5
6

# useAtomsDevtools

useAtomsDevtools 管理所有 atom 的 ReduxDevTools 扩展。

useAtomsDevtools 目前还在实验阶段

import { useAtomsDevtools } from 'jotai-devtools/utils';

const AtomsDevtools = ({ children }) => {
  useAtomsDevtools('demo')
  return children
}

function App()  {
  return (
    <AtomsDevtools>
      <Counter />
    </AtomsDevtools>
  )
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# Demo

jotai-demo (opens new window)

# References