JotaiJotai

状態
Primitive and flexible state management for React

Composing atoms

The atom function provided by library is very primitive, but it's also so flexible that you can combine multiple atoms to implement a functionality.

Note again that atom() creates an atom config, which is an object to define a behavior of the atom.

Let's recap how we can derive an atom.

Basic derived atoms

Here's one of the simplest examples of a derived atom:

export const textAtom = atom('hello')
export const textLenAtom = atom((get) => get(textAtom).length)

The textLenAtom is called read-only atom, because it doesn't have a write function defined.

The following is another simple example with the write function:

const textAtom = atom('hello')
export const textUpperCaseAtom = atom(
(get) => get(textAtom).toUpperCase(),
(_get, set, newText) => set(textAtom, newText)
)

In this case, textUpperCaseAtom is capable to set the original textAtom. So, we can only export textUpperCaseAtom and can hide textAtom in a smaller scope.

Now, let's see some real examples.

Overriding default atom values

Suppose we have a read-only atom. Obviously read-only atoms are not writable, but we can combine two atoms to override the read-only atom value.

const rawNumberAtom = atom(10.1) // can be exported
const roundNumberAtom = atom((get) => Math.round(get(rawNumberAtom)))
const overwrittenAtom = atom(null)
export const numberAtom = atom(
(get) => get(overwrittenAtom) ?? get(roundNumberAtom),
(get, set, newValue) => {
const nextValue =
typeof newValue === 'function' ? newValue(get(numberAtom)) : newValue
set(overwrittenAtom, nextValue)
}
)

The final numberAtom just works like a normal primitive atom like atom(10). If you set a number value, it will override the overwrittenAtom value, and if you set null, it will be the roundNumberAtom value.

The reusable implementation is available as atomWithDefault in jotai/utils. See atomWithDefault.

Next, let's see another example to sync with external value.

Syncing atom values with external values

There are some external values we want to deal with. localStorage is the one. Another is window.title.

Let's see how to create an atom that is in sync with localStorage.

const baseAtom = atom(localStorage.getItem('mykey') || '')
export const persistedAtom = atom(
(get) => get(baseAtom),
(get, set, newValue) => {
const nextValue =
typeof newValue === 'function' ? newValue(get(baseAtom)) : newValue
set(baseAtom, nextValue)
localStorage.setItem('mykey', nextValue)
}
)

The persistedAtom works like a primitive atom, but its value is persisted in localStorage.

The reusable implementation is available as atomWithStorage in jotai/utils. See atomWithStorage.

There is a caveat with this usage. While atom config doesn't hold a value, the external value is a singleton value. So, if we use this atom in two different Providers, There will be an inconsistency between the two persistedAtom values. This could be solved if the external value had a subscription mechanism.

For example, atomWithProxy in jotai-valtio comes with subscription, so we don't have such a limitation. Values in different Providers will be in sync.

Back to the main topic, let's explore another example.

Extending atoms with atomWith* utils

We have several utils whose names start with atomWith. They create an atom with a certain functionality. Unfortunately, we can't combine two atom utils. For example, atomWithStorage and atomWithReducer can't be used to define a single atom.

In such a case, we need to derive an atom by ourselves. Let's try adding reducer functionality to atomWithStorage:

const reducer = ...
const baseAtom = atomWithStorage('mykey', '')
export const derivedAtom = atom(
(get) => get(baseAtom),
(get, set, action) => {
set(baseAtom, reducer(get(baseAtom), action))
}
)

This is easy, because in this case, atomWithReducer is a simple implementation compared to atomWithStorage.

For more complex cases, it wouldn't be very easy. It would still be a open research field.

Finally, let's see another example with actions.

Action atoms

This should be known pattern as it's described in README. Nonetheless, it might to be useful to revisit.

Let's create a counter that you can only increment or decrement by one.

One solution is atomWithReducer:

const countAtom = atomWithReducer(0, (prev, action) => {
if (action === 'INC') {
return prev + 1
}
if (action === 'DEC') {
return prev - 1
}
throw new Error('unknown action')
})

This is fine, but not very atomic. If we want to get benefit from code splitting / lazy loading, We want to create write only atoms, or action atoms.

const baseAtom = atom(0) // do not export
export const countAtom = atom((get) => get(baseAtom)) // read only
export const incAtom = atom(null, (_get, set) => {
set(baseAtom, (prev) => prev + 1)
})
export const decAtom = atom(null, (_get, set) => {
set(baseAtom, (prev) => prev - 1)
})

This is more atomic and looks like a Jotai way.

You can also create an action atom that will call another action atom:

// continued from the previous code
export const dispatchAtom = atom(null, (_get, set, action) => {
if (action === 'INC') {
set(incAtom)
}
if (action === 'DEC') {
set(decAtom)
}
throw new Error('unknown action')
}

Why do we want it? Because it will be used only when needed. It allows code splitting and dead code elimination.

In summary

Atoms are building block. By composing atoms based on other atoms, we can implement complicated logic. This is not only for read derived atoms, but also for write action atoms.

Essentially, atoms are like functions, so composing atoms is like composing functions with other functions.

Note: We mentioned that our atoms can contain any kind of data, it can be a string, Blob, Observer, anything really. There is just one exception. Because derived atoms are defined using a function, Jotai will not understand if we pass it a function that isn't exactly a pure getter. So what you can do is simply wrap your function in an object.

const doublerAtom = atom({ callback: (n) => n * 2 })
// Usage
const [doubler] = useAtom(doublerAtom)
const doubledValue = doubler.callback(50) // Will compute to 100