Skip to content

Commit 83f8aa3

Browse files
committed
feat: add atom
1 parent 656ae28 commit 83f8aa3

3 files changed

Lines changed: 336 additions & 0 deletions

File tree

src/utils/atom.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Atom
2+
3+
Atom is the root primitive that is missing in React (could be extended for others). It is a simple object that holds a value and can be set and read independently of the React lifecycle:
4+
- It makes it easy to hold a singleton state (for example auth token)
5+
- It makes it easy to hold a state that is not related to the component lifecycle (for example background jobs in React Native)
6+
- It can potentially sync state with other frameworks than React
7+
- It integrates fully with the React lifecycle, as one would expect, but you can also use it without triggering a re-render (by using `.get()` or `.value`)
8+
9+
## createAtom
10+
11+
The base for everything is create atom. This holds a value and can be read and set.
12+
13+
```typescript
14+
const atom = createAtom(5)
15+
16+
console.log(atom.value) // 5
17+
atom.set(6)
18+
console.log(atom.value) // 6
19+
```
20+
## .set()
21+
Similarly to setState you can supply a callback to the set method. This callback will receive the current value and should return the new value, which results in an atomic update
22+
```typescript
23+
atom.set(v => v + 1)
24+
```
25+
26+
## .get()
27+
You can read the value of the atom using the `.get()` method or by accessing the `.value` property
28+
```typescript
29+
console.log(atom.get()) // 7
30+
console.log(atom.value) // 7
31+
```
32+
33+
## useAtom
34+
35+
The `useAtom` hook is the main way to interact with atoms in a React component or custom hook. It returns the current value. The component will re-render when the value changes.
36+
37+
We considered returning the same signature as `useState` for familiarity but decided against it. The `.set()` function does not care whether it's executed in a React context or not, so it wouldn't add any value.
38+
39+
```typescript
40+
function MyComponent() {
41+
const value = useAtom(atom)
42+
const increment = () => atom.set(v => v + 1)
43+
44+
return <div onClick={increment}>{value}</div>
45+
}
46+
```
47+
48+
## Persisting atoms
49+
50+
You can persist atoms using the `persistAtom` function. This will store the atom in localStorage and rehydrate it on page load. It takes the atom, a key and any compatible storage (localStorage, sessionStorage or AsyncStorage). It basically accepts any key value store that has a `getItem` and `setItem` method. By default it will persist the atom every 100ms, but you can pass any debounce time in milliseconds as the third argument.
51+
52+
```typescript
53+
const atom = createAtom(5)
54+
persistAtom(atom, 'my-atom-key', localStorage)
55+
```

src/utils/atom.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { renderHook, act } from '@testing-library/react-hooks'
2+
3+
import {
4+
AtomAsyncStorage,
5+
AtomStorage, createAtom, persistAtom, useAtom,
6+
} from './atom'
7+
8+
describe('createAtom', () => {
9+
test('get initial value', () => {
10+
const counter = createAtom(1)
11+
12+
expect(counter.get()).toBe(1)
13+
})
14+
15+
test('set value', () => {
16+
const counter = createAtom(1)
17+
18+
counter.set(2)
19+
20+
expect(counter.get()).toBe(2)
21+
})
22+
23+
test('set value with setter', () => {
24+
const counter = createAtom(1)
25+
26+
counter.set((val) => val + 1)
27+
28+
expect(counter.get()).toBe(2)
29+
})
30+
})
31+
32+
describe('persistence', () => {
33+
test('should read null store', () => {
34+
const storage: AtomStorage = {
35+
getItem: jest.fn(),
36+
setItem: jest.fn(),
37+
}
38+
const counter = createAtom(1)
39+
const val = persistAtom(counter, 'counter', storage)
40+
41+
expect(val).toBe(null)
42+
expect(counter.value).toBe(1)
43+
44+
expect(storage.getItem).toHaveBeenCalledWith('counter')
45+
expect(storage.getItem).toHaveBeenCalledTimes(1)
46+
})
47+
48+
test('should await null store', async () => {
49+
const storage: AtomAsyncStorage = {
50+
getItem: jest.fn(async () => Promise.resolve('3')),
51+
setItem: jest.fn(),
52+
}
53+
const counter = createAtom(1)
54+
const val = await persistAtom(counter, 'counter', storage)
55+
56+
expect(val).toBe(3)
57+
expect(counter.value).toBe(3)
58+
59+
expect(storage.getItem).toHaveBeenCalledWith('counter')
60+
expect(storage.getItem).toHaveBeenCalledTimes(1)
61+
})
62+
63+
test('should read store with value', () => {
64+
const storage: AtomStorage = {
65+
getItem: jest.fn(() => '2'),
66+
setItem: jest.fn(),
67+
}
68+
const counter = createAtom(1)
69+
const val = persistAtom(counter, 'counter', storage)
70+
71+
expect(val).toBe(2)
72+
expect(counter.value).toBe(2)
73+
74+
expect(storage.getItem).toHaveBeenCalledWith('counter')
75+
expect(storage.getItem).toHaveBeenCalledTimes(1)
76+
})
77+
78+
test('should write store with value', () => {
79+
jest.useFakeTimers()
80+
const storage: AtomStorage = {
81+
getItem: jest.fn(() => '2'),
82+
setItem: jest.fn(),
83+
}
84+
const counter = createAtom(1)
85+
persistAtom(counter, 'counter', storage)
86+
87+
counter.set(3)
88+
89+
jest.runAllTimers()
90+
91+
expect(storage.setItem).toHaveBeenCalledWith('counter', '3')
92+
expect(storage.setItem).toHaveBeenCalledTimes(1)
93+
})
94+
})
95+
96+
describe('listener', () => {
97+
test('addListener', () => {
98+
const counter = createAtom(1)
99+
100+
const spy = jest.fn()
101+
counter.addListener(spy)
102+
103+
counter.set((val) => val + 1)
104+
105+
expect(spy).toHaveBeenCalledWith(2, 1)
106+
})
107+
108+
test('remove listener', () => {
109+
const counter = createAtom(1)
110+
111+
const spy = jest.fn()
112+
const { remove } = counter.addListener(spy)
113+
114+
counter.set((val) => val + 1)
115+
116+
remove()
117+
118+
counter.set((val) => val + 1)
119+
120+
expect(spy).toHaveBeenCalledWith(2, 1)
121+
expect(spy).not.toHaveBeenCalledWith(3, 2)
122+
})
123+
})
124+
125+
describe('useAtom', () => {
126+
test('initial value', () => {
127+
const counter = createAtom(1)
128+
129+
const { result } = renderHook(() => useAtom(counter))
130+
131+
expect(result.current).toBe(1)
132+
})
133+
134+
test('increment value', () => {
135+
const counter = createAtom(1)
136+
137+
const { result } = renderHook(() => useAtom(counter))
138+
139+
expect(result.current).toBe(1)
140+
141+
act(() => {
142+
counter.set((val) => val + 1)
143+
})
144+
145+
expect(result.current).toBe(2)
146+
})
147+
})

src/utils/atom.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/* eslint-disable functional/immutable-data */
2+
import { useState, useEffect } from 'react'
3+
4+
type JSONValue =
5+
| string
6+
| number
7+
| boolean
8+
| JSONObject
9+
| JSONArray;
10+
11+
interface JSONObject {
12+
readonly [x: string]: JSONValue;
13+
}
14+
15+
type ToPrimitive<T> =
16+
T extends string ? string
17+
: T extends number ? number
18+
: T extends boolean ? boolean
19+
: T;
20+
21+
export type AtomStorage = {
22+
readonly setItem: (key: string, value: string) => void,
23+
readonly getItem: (key: string) => string | null
24+
}
25+
26+
export type AtomAsyncStorage = {
27+
readonly setItem: (key: string, value: string) => Promise<void>,
28+
readonly getItem: (key: string) => Promise<string | null>
29+
}
30+
31+
interface JSONArray extends Array<JSONValue> { }
32+
33+
export class Atom<T extends JSONValue> {
34+
/* eslint-disable functional/prefer-readonly-type */
35+
#value: T
36+
37+
/* eslint-disable functional/prefer-readonly-type */
38+
#subscribers: readonly ((newVal: T, prevVal: T) => void)[]
39+
40+
constructor(initialValue: T) {
41+
this.#value = initialValue
42+
this.#subscribers = []
43+
}
44+
45+
get value() {
46+
return this.#value
47+
}
48+
49+
get() {
50+
return this.#value
51+
}
52+
53+
addListener(cb: (newVal: T, prevVal: T) => void) {
54+
this.#subscribers = [...this.#subscribers, cb]
55+
const remove = () => {
56+
this.#subscribers = this.#subscribers.filter((fn) => fn !== cb)
57+
}
58+
return { remove }
59+
}
60+
61+
set(value: (T | ((val: T) => T))) {
62+
const prevValue = this.#value
63+
this.#value = typeof value === 'function' ? value(this.#value) : value
64+
if (this.#value !== prevValue) {
65+
this.#subscribers.forEach((sub) => {
66+
sub(this.#value, prevValue)
67+
})
68+
}
69+
}
70+
}
71+
72+
export function createAtom<T extends JSONValue>(initialValue: ToPrimitive<T>) {
73+
return new Atom<ToPrimitive<T>>(initialValue)
74+
}
75+
76+
export function useAtom<T extends JSONValue>(atom: Atom<T>) {
77+
const [val, setVal] = useState<T>(atom.value)
78+
79+
useEffect(() => {
80+
const { remove } = atom.addListener((newVal) => {
81+
setVal(newVal)
82+
})
83+
return remove
84+
}, [atom])
85+
86+
return val
87+
}
88+
89+
const debounce = (fn: () => void, delay: number) => {
90+
let timeoutId: ReturnType<typeof setTimeout>
91+
return () => {
92+
clearTimeout(timeoutId)
93+
timeoutId = setTimeout(fn, delay)
94+
}
95+
}
96+
97+
export function persistAtom<T extends JSONValue, TStorage extends AtomStorage | AtomAsyncStorage>(
98+
atom: Atom<T>,
99+
key: string,
100+
Storage: TStorage,
101+
opts?: { readonly debounce?: number },
102+
): TStorage extends AtomAsyncStorage ? Promise<T | null> : T | null {
103+
const valueOrPromise = Storage.getItem(key)
104+
105+
const startSubscribing = () => {
106+
atom.addListener(debounce(() => {
107+
void Storage.setItem(key, JSON.stringify(atom.value))
108+
}, opts?.debounce ?? 100))
109+
}
110+
111+
const handleValue = (value: string | null) => {
112+
if (value) {
113+
const valueAsJSON = JSON.parse(value) as T
114+
115+
atom.set(valueAsJSON)
116+
117+
startSubscribing()
118+
119+
return valueAsJSON as T
120+
}
121+
122+
startSubscribing()
123+
124+
return null
125+
}
126+
127+
if (valueOrPromise instanceof Promise) {
128+
// @ts-expect-error contained
129+
return valueOrPromise.then(handleValue)
130+
}
131+
132+
// @ts-expect-error contained
133+
return handleValue(valueOrPromise)
134+
}

0 commit comments

Comments
 (0)