Skip to content

Commit 33fdf9b

Browse files
authored
Merge pull request #2 from jotaijs/atom-tree
feat: atomTree
2 parents f0009ae + c1d3356 commit 33fdf9b

File tree

3 files changed

+326
-0
lines changed

3 files changed

+326
-0
lines changed

README.md

+104
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,107 @@ const todoFamily = atomFamily(
109109
### Codesandbox
110110

111111
<CodeSandbox id="huxd4i" />
112+
113+
---
114+
115+
## atomTree
116+
117+
The **atomTree** utility provides a hierarchical way to create, reuse, and remove Jotai atoms. Each atom is associated with a unique path, which is an array of unknown types. When you request the same path multiple times, `atomTree` ensures that the same atom instance is returned. You can also remove a specific atom or an entire subtree of atoms when they are no longer needed.
118+
119+
Use `atomTree` when you anticipate a large number of potential paths and want to:
120+
121+
- **Reuse the same atom** for repeated paths.
122+
- **Clean up** unwanted atoms easily, including entire subtrees.
123+
124+
```js
125+
import { atom } from 'jotai'
126+
import { atomTree } from 'jotai-family'
127+
128+
// Create a tree instance, passing a factory function
129+
// that takes a path array and returns a new atom.
130+
const tree = atomTree((path) => atom(path.join('-')))
131+
132+
// Create or retrieve the atom at ['foo', 'bar']
133+
const atomA = tree(['foo', 'bar'])
134+
const atomB = tree(['foo', 'bar'])
135+
136+
// atomA and atomB are the same instance.
137+
console.log(atomA === atomB) // true
138+
139+
// Remove the atom at ['foo', 'bar']
140+
// (and optionally remove its entire subtree)
141+
tree.remove(['foo', 'bar'])
142+
```
143+
144+
### API
145+
146+
#### Creating the tree
147+
148+
Creates a new hierarchical tree of Jotai atoms. It accepts a **initializePathAtom** function that receives a path array and returns an atom. The returned function can be used to create, retrieve, and remove atoms at specific paths.
149+
150+
```ts
151+
function atomTree<Path, AtomType>(
152+
initializePathAtom: (path: Path) => AtomType
153+
): {
154+
(path: Path): AtomType
155+
remove(path: Path, removeSubTree?: boolean): void
156+
getSubTree(path: Path): Node<AtomType> | undefined
157+
getNodePath(path: Path): Node<AtomType>[]
158+
}
159+
160+
type Node<AtomType> = {
161+
atom?: AtomType
162+
children?: Map<PathSegment, Node<AtomType>>
163+
}
164+
```
165+
166+
### Creating Path Atoms
167+
```ts
168+
tree(path: Path): AtomType
169+
```
170+
Creates (or retrieves) an atom at the specified path. Subsequent calls with the same path return the same atom instance.
171+
172+
### Removing Path Atoms
173+
```ts
174+
tree.remove(path: Path, removeSubTree = false): void
175+
```
176+
Removes the atom at the specified path. If `removeSubTree` is `true`, all child paths under that path are also removed.
177+
178+
This method removes the atom at the specified path. If `removeSubTree` is `true`, it also removes all child paths under that path.
179+
180+
### Retrieving A Subtree
181+
```ts
182+
tree.getSubTree(path: Path): Node<AtomType> | undefined
183+
```
184+
185+
Retrieves the internal node representing the specified path. This is useful for inspecting the tree structure. The node structure is as follows:
186+
187+
```ts
188+
type Node<AtomType> = {
189+
atom?: AtomType
190+
children?: Map<PathSegment, Node<AtomType>>
191+
}
192+
```
193+
194+
### Retrieving A Node Path
195+
```ts
196+
tree.getNodePath(path: Path): Node<AtomType>[]
197+
```
198+
Returns an array of node objects from the root node to the node at the specified path, inclusive.
199+
200+
## Usage Example
201+
202+
```js
203+
import { atom } from 'jotai'
204+
import { atomTree } from 'jotai-family'
205+
206+
const btree = atomTree((path) => atom(`Data for path: ${path}`))
207+
208+
// Create or retrieve the atom at [true, false]
209+
const userAtom = btree([true, false])
210+
211+
console.log(store.get(userAtom)) // 'Data for path: true,false'
212+
213+
// Remove the atom (and optionally remove its subtree)
214+
btree.remove([true,false])
215+
```

src/atomTree.ts

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { Atom } from 'jotai'
2+
3+
type Node<AtomType extends Atom<unknown> = Atom<unknown>> = {
4+
children?: Map<unknown, Node<AtomType>>
5+
atom?: AtomType
6+
}
7+
8+
type AtomTree<Path extends unknown[], AtomType extends Atom<unknown>> = {
9+
(path: Path): AtomType
10+
remove(path?: Path, removeSubTree?: boolean): void
11+
getSubTree(path?: Path): Node<AtomType>
12+
getNodePath(path?: Path): Node<AtomType>[]
13+
}
14+
15+
/**
16+
* Creates a hierarchical structure of Jotai atoms.
17+
*
18+
* @template AtomType - The type of atom returned by the initialization function.
19+
* @param initializePathAtom - A function that takes a path array and returns an Atom.
20+
* @returns A function for creating and managing hierarchical atoms (with additional methods).
21+
*/
22+
export function atomTree<
23+
Path extends unknown[],
24+
AtomType extends Atom<unknown>,
25+
>(initializePathAtom: (path: Path) => AtomType): AtomTree<Path, AtomType> {
26+
const root: Node<AtomType> = {}
27+
const defaultPath = [] as unknown[] as Path
28+
29+
/**
30+
* Creates or retrieves an atom at the specified path in the hierarchy.
31+
*
32+
* @param path - Array of keys representing the location in the hierarchy.
33+
* @returns The Jotai atom for the specified path.
34+
*/
35+
const createAtom: AtomTree<Path, AtomType> = (path) => {
36+
let node = root
37+
for (const key of path) {
38+
node.children ??= new Map()
39+
if (!node.children.has(key)) {
40+
node.children.set(key, {})
41+
}
42+
node = node.children.get(key)!
43+
}
44+
node.atom ??= initializePathAtom(path)
45+
return node.atom
46+
}
47+
48+
/**
49+
* Removes an atom (and optionally its subtree) at the specified path.
50+
*
51+
* @param path - Array of keys representing the location in the hierarchy. Defaults to [] (root).
52+
* @param removeSubTree - If true, removes all children of that path as well. Defaults to false.
53+
* @throws Error if the path does not exist.
54+
*/
55+
createAtom.remove = (path = defaultPath, removeSubTree = false) => {
56+
const nodePath = createAtom.getNodePath(path)
57+
const node = nodePath.at(-1)!
58+
delete node.atom
59+
if (removeSubTree) {
60+
delete node.children
61+
}
62+
// delete empty subtrees from bottom to top
63+
for (let i = nodePath.length - 1; i >= 0; i--) {
64+
const node = nodePath[i]!
65+
if (!node.children?.size && i > 0) {
66+
const parentNode = nodePath[i - 1]!
67+
parentNode.children!.delete(path[i]!)
68+
if (!parentNode.children!.size) {
69+
delete parentNode.children
70+
}
71+
} else {
72+
break
73+
}
74+
}
75+
}
76+
77+
/**
78+
* Retrieves the internal node (subtree) at a specified path.
79+
*
80+
* @param path - Array of keys representing the location in the hierarchy. Defaults to [] (root).
81+
* @returns A Node object with possible `children` and `atom`.
82+
* @throws Error if the path does not exist.
83+
*/
84+
createAtom.getSubTree = (path = defaultPath) => {
85+
let node: Node<AtomType> | undefined = root
86+
for (const key of path) {
87+
node = node.children?.get(key)
88+
if (!node) {
89+
throw new Error('Path does not exist')
90+
}
91+
}
92+
return node
93+
}
94+
95+
/**
96+
* Retrieves the internal node (subtree) at a specified path.
97+
*
98+
* @param path - Array of keys representing the location in the hierarchy. Defaults to [] (root).
99+
* @returns An array of Node objects representing the path.
100+
* @throws Error if the path does not exist.
101+
*/
102+
createAtom.getNodePath = (path = defaultPath) => {
103+
const nodePath = [root]
104+
let node: Node<AtomType> | undefined = root
105+
for (const key of path) {
106+
node = node.children?.get(key)
107+
if (!node) {
108+
throw new Error('Path does not exist')
109+
}
110+
nodePath.push(node)
111+
}
112+
return nodePath
113+
}
114+
115+
return createAtom
116+
}

tests/atomTree.test.ts

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { atom, createStore } from 'jotai/vanilla'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { atomTree } from '../src/atomTree'
4+
5+
describe('atomTree', () => {
6+
it('creates atoms at a given path', function test() {
7+
const store = createStore()
8+
const initializeAtom = vi.fn((path: number[]) => atom(path.join('-')))
9+
const tree = atomTree(initializeAtom)
10+
11+
const atomA = tree([1, 2, 3])
12+
expect(initializeAtom).toHaveBeenCalledTimes(1)
13+
expect(store.get(atomA)).toBe('1-2-3')
14+
15+
const atomB = tree([1, 2, 3])
16+
expect(initializeAtom).toHaveBeenCalledTimes(1)
17+
// Should return the same atom instance if called with the same path
18+
expect(atomA).toBe(atomB)
19+
expect(store.get(atomB)).toBe('1-2-3')
20+
21+
const atomC = tree([1, 3])
22+
expect(initializeAtom).toHaveBeenCalledTimes(2)
23+
expect(store.get(atomC)).toBe('1-3')
24+
})
25+
26+
it('removes an atom at a given path', function test() {
27+
const store = createStore()
28+
const initializeAtom = (path: (number | string)[]) => atom(path.join('-'))
29+
const tree = atomTree(initializeAtom)
30+
31+
// Create an atom
32+
const myAtom = tree([1, 'test'])
33+
expect(store.get(myAtom)).toBe('1-test')
34+
35+
// Remove it
36+
tree.remove([1, 'test'])
37+
// Re-creating should yield a new atom
38+
const newAtom = tree([1, 'test'])
39+
expect(newAtom).not.toBe(myAtom)
40+
})
41+
42+
it('removes an atom subtree correctly', function test() {
43+
const store = createStore()
44+
const initializeAtom = (path: string[]) => atom(path.join('-'))
45+
const tree = atomTree(initializeAtom)
46+
47+
const atomA = tree(['parent', 'childA'])
48+
expect(store.get(atomA)).toBe('parent-childA')
49+
50+
const atomB = tree(['parent', 'childB'])
51+
expect(store.get(atomB)).toBe('parent-childB')
52+
53+
// Remove whole subtree under 'parent'
54+
tree.remove(['parent'], true)
55+
56+
// Re-create them; they should be new atoms
57+
const atomA2 = tree(['parent', 'childA'])
58+
const atomB2 = tree(['parent', 'childB'])
59+
expect(atomA2).not.toBe(atomA)
60+
expect(atomB2).not.toBe(atomB)
61+
})
62+
63+
it('throws if path does not exist when removing without prior creation', () => {
64+
const tree = atomTree((path) => atom(path.join('-')))
65+
expect(() => tree.remove(['nonexistent'])).toThrowError(
66+
'Path does not exist'
67+
)
68+
})
69+
70+
it('retrieves subtree node correctly', function test() {
71+
const store = createStore()
72+
const tree = atomTree((path: string[]) => atom(path.join('-')))
73+
74+
const myAtom = tree(['foo', 'bar'])
75+
expect(store.get(myAtom)).toBe('foo-bar')
76+
77+
// getSubTree should give us the correct node
78+
const subTree = tree.getSubTree(['foo', 'bar'])
79+
expect(subTree.atom).toBe(myAtom)
80+
})
81+
82+
it('throws when retrieving subtree that does not exist', function test() {
83+
const tree = atomTree((path: string[]) => atom(path.join('-')))
84+
expect(() => tree.getSubTree(['no', 'such', 'thing'])).toThrowError(
85+
'Path does not exist'
86+
)
87+
})
88+
89+
it('retrieves the node path correctly', function test() {
90+
const tree = atomTree((path) => atom(path.join('-')))
91+
92+
tree(['branch', 'leaf'])
93+
const nodePath = tree.getNodePath(['branch', 'leaf'])
94+
expect(nodePath).toHaveLength(3)
95+
expect(nodePath[0]!.children?.has('branch')).toBe(true)
96+
expect(nodePath[1]!.children?.has('leaf')).toBe(true)
97+
expect(nodePath[2]!.atom).toBeDefined()
98+
})
99+
100+
it('throws when retrieving node path that does not exist', function test() {
101+
const tree = atomTree((path: unknown[]) => atom(path.join('-')))
102+
expect(() => tree.getNodePath(['no', 'such', 'thing'])).toThrowError(
103+
'Path does not exist'
104+
)
105+
})
106+
})

0 commit comments

Comments
 (0)