Skip to content

Commit 12bcea5

Browse files
committed
feat: add atomTree utility
- createAtom - createAtom.remove - createAtom.getSubTree - createAtom.getNodePath
1 parent 1431682 commit 12bcea5

File tree

3 files changed

+318
-40
lines changed

3 files changed

+318
-40
lines changed

README.md

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

111111
<CodeSandbox id="huxd4i" />
112+
113+
---
114+
115+
# atomTree
116+
117+
[atomTree](https://github.com/jotaijs/jotai-family/blob/main/src/atomTree.ts) is a tree structure that allows you to create and remove atoms at a given path.
118+
119+
Use `atomTree` when you need a hierarchical way to store atoms, particularly if you expect many potential paths and want to reuse the same atom for repeated paths while also having an easy cleanup mechanism for subtrees.
120+
121+
```js
122+
import { atom } from 'jotai'
123+
import { atomTree } from 'jotai-family'
124+
125+
const tree = atomTree((path) => atom(path.join('-')))
126+
127+
const atomA = tree(['foo', 'bar'])
128+
const atomB = tree(['foo', 'bar'])
129+
130+
// Remove the atom at the given path
131+
tree.remove(['foo', 'bar'])
132+
```
133+
134+
## atomTree
135+
136+
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.
137+
138+
Use `atomTree` when you anticipate a large number of potential paths and want to:
139+
140+
- **Reuse the same atom** for repeated paths.
141+
- **Clean up** unwanted atoms easily, including entire subtrees.
142+
143+
```js
144+
import { atom } from 'jotai'
145+
import { atomTree } from 'jotai-family'
146+
147+
// Create a tree instance, passing a factory function
148+
// that takes a path array and returns a new atom.
149+
const tree = atomTree((path) => atom(path.join('-')))
150+
151+
// Create or retrieve the atom at ['foo', 'bar']
152+
const atomA = tree(['foo', 'bar'])
153+
const atomB = tree(['foo', 'bar'])
154+
155+
// atomA and atomB are the same instance.
156+
console.log(atomA === atomB) // true
157+
158+
// Remove the atom at ['foo', 'bar']
159+
// (and optionally remove its entire subtree)
160+
tree.remove(['foo', 'bar'])
161+
```
162+
163+
### API
164+
165+
#### Creating the tree
166+
167+
Creates a new hierarchical tree of Jotai atoms. It accepts a **factory** function that receives a path array and returns an atom.
168+
169+
```ts
170+
type Path = string[] // Or any other array type
171+
type AtomType = Atom<unknown>
172+
173+
function atomTree<Path, AtomType>(
174+
initializePathAtom: (path: Path) => AtomType
175+
): {
176+
(path: Path): AtomType
177+
remove(path: Path, removeSubTree?: boolean): void
178+
getSubTree(path: Path): Node<AtomType> | undefined
179+
getNodePath(path: Path): Node<AtomType>[]
180+
}
181+
```
182+
183+
- **`initializePathAtom`**: A function invoked whenever the tree needs to create a new atom. Receives the `path` as an argument and must return a Jotai atom.
184+
185+
The returned function has four main operations:
186+
187+
1. **`tree(path: Path): AtomType`**
188+
Creates (or retrieves) an atom at the specified path. Subsequent calls with the same path return the same atom instance.
189+
190+
2. **`tree.remove(path: Path, removeSubTree = false): void`**
191+
Removes the atom at the specified path. If `removeSubTree` is `true`, all child paths under that path are also removed.
192+
193+
3. **`tree.getSubTree(path: Path): Node<AtomType> | undefined`**
194+
Retrieves the internal node representing the specified path. This is useful for inspecting the tree structure. The node structure is as follows:
195+
196+
```ts
197+
type Node<AtomType> = {
198+
atom?: AtomType
199+
children?: Map<PathSegment, Node<AtomType>>
200+
}
201+
```
202+
203+
4. **`tree.getNodePath(path: Path): Node<AtomType>[]`**
204+
Returns an array of node objects from the root node to the node at the specified path, inclusive.
205+
206+
## Usage Example
207+
208+
```js
209+
import { atom } from 'jotai'
210+
import { atomTree } from 'jotai-family'
211+
212+
const btree = atomTree((path) => atom(`Data for path: ${path}`))
213+
214+
// Create or retrieve the atom at [true, false]
215+
const userAtom = btree([true, false])
216+
217+
console.log(store.get(userAtom)) // 'Data for path: true,false'
218+
219+
// Remove the atom (and optionally remove its subtree)
220+
btree.remove([true,false])
221+
```

src/atomTree.ts

+102-40
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,116 @@
11
import { Atom } from 'jotai'
22

3-
export function atomTree<Param, AtomType extends Atom<unknown>>(
4-
initializeAtom: (path: [Param, ...Param[]]) => AtomType
5-
) {
6-
type Node = { m?: Map<Param, Node>; v?: AtomType }
7-
const root = new Map<Param, Node>()
8-
9-
const createAtom = (path: [Param, ...Param[]]): AtomType => {
10-
if (path.length === 0) {
11-
throw new Error('Path must have at least one key')
12-
}
13-
let current = root
14-
for (let i = 0; i < path.length; i++) {
15-
const key = path[i]!
16-
let node = current.get(key)
17-
if (!node) {
18-
node = {}
19-
current.set(key, node)
20-
}
21-
if (i < path.length - 1) {
22-
current = node.m = new Map()
23-
continue
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 = new Array() 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, {})
2441
}
25-
return (node.v = initializeAtom(path))
42+
node = node.children.get(key)!
2643
}
27-
throw new Error('Unreachable')
44+
node.atom ??= initializePathAtom(path)
45+
return node.atom
2846
}
29-
createAtom.remove = (path: [Param, ...Param[]]) => {
30-
if (path.length === 0) {
31-
throw new Error('Path must have at least one key')
32-
}
33-
const current = root
34-
const nodePath: Node[] = []
35-
for (const segment of path) {
36-
const node = current.get(segment)
37-
if (!node) {
38-
break
39-
}
40-
nodePath.push(node)
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
4161
}
42-
delete nodePath[nodePath.length - 1]!.v
43-
// delete empty subtrees
62+
// delete empty subtrees from bottom to top
4463
for (let i = nodePath.length - 1; i >= 0; i--) {
45-
const current = nodePath[i]!
46-
if (current.m?.size === 0 && i > 0) {
47-
nodePath[i - 1]!.m!.delete(path[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+
}
4871
} else {
4972
break
5073
}
5174
}
5275
}
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 = 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+
53115
return createAtom
54116
}

tests/atomTree.test.ts

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { atom, createStore } from 'jotai/vanilla'
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)