atomirx 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1666 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +1440 -0
- package/coverage/coverage-final.json +14 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +131 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/core/atom.ts.html +889 -0
- package/coverage/src/core/batch.ts.html +223 -0
- package/coverage/src/core/define.ts.html +805 -0
- package/coverage/src/core/emitter.ts.html +919 -0
- package/coverage/src/core/equality.ts.html +631 -0
- package/coverage/src/core/hook.ts.html +460 -0
- package/coverage/src/core/index.html +281 -0
- package/coverage/src/core/isAtom.ts.html +100 -0
- package/coverage/src/core/isPromiseLike.ts.html +133 -0
- package/coverage/src/core/onCreateHook.ts.html +136 -0
- package/coverage/src/core/scheduleNotifyHook.ts.html +94 -0
- package/coverage/src/core/types.ts.html +523 -0
- package/coverage/src/core/withUse.ts.html +253 -0
- package/coverage/src/index.html +116 -0
- package/coverage/src/index.ts.html +106 -0
- package/dist/core/atom.d.ts +63 -0
- package/dist/core/atom.test.d.ts +1 -0
- package/dist/core/atomState.d.ts +104 -0
- package/dist/core/atomState.test.d.ts +1 -0
- package/dist/core/batch.d.ts +126 -0
- package/dist/core/batch.test.d.ts +1 -0
- package/dist/core/define.d.ts +173 -0
- package/dist/core/define.test.d.ts +1 -0
- package/dist/core/derived.d.ts +102 -0
- package/dist/core/derived.test.d.ts +1 -0
- package/dist/core/effect.d.ts +120 -0
- package/dist/core/effect.test.d.ts +1 -0
- package/dist/core/emitter.d.ts +237 -0
- package/dist/core/emitter.test.d.ts +1 -0
- package/dist/core/equality.d.ts +62 -0
- package/dist/core/equality.test.d.ts +1 -0
- package/dist/core/hook.d.ts +134 -0
- package/dist/core/hook.test.d.ts +1 -0
- package/dist/core/isAtom.d.ts +9 -0
- package/dist/core/isPromiseLike.d.ts +9 -0
- package/dist/core/isPromiseLike.test.d.ts +1 -0
- package/dist/core/onCreateHook.d.ts +79 -0
- package/dist/core/promiseCache.d.ts +134 -0
- package/dist/core/promiseCache.test.d.ts +1 -0
- package/dist/core/scheduleNotifyHook.d.ts +51 -0
- package/dist/core/select.d.ts +151 -0
- package/dist/core/selector.test.d.ts +1 -0
- package/dist/core/types.d.ts +279 -0
- package/dist/core/withUse.d.ts +38 -0
- package/dist/core/withUse.test.d.ts +1 -0
- package/dist/index-2ok7ilik.js +1217 -0
- package/dist/index-B_5SFzfl.cjs +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +20 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/react/index.cjs +30 -0
- package/dist/react/index.d.ts +7 -0
- package/dist/react/index.js +823 -0
- package/dist/react/rx.d.ts +250 -0
- package/dist/react/rx.test.d.ts +1 -0
- package/dist/react/strictModeTest.d.ts +10 -0
- package/dist/react/useAction.d.ts +381 -0
- package/dist/react/useAction.test.d.ts +1 -0
- package/dist/react/useStable.d.ts +183 -0
- package/dist/react/useStable.test.d.ts +1 -0
- package/dist/react/useValue.d.ts +134 -0
- package/dist/react/useValue.test.d.ts +1 -0
- package/package.json +57 -0
- package/scripts/publish.js +198 -0
- package/src/core/atom.test.ts +369 -0
- package/src/core/atom.ts +189 -0
- package/src/core/atomState.test.ts +342 -0
- package/src/core/atomState.ts +256 -0
- package/src/core/batch.test.ts +257 -0
- package/src/core/batch.ts +172 -0
- package/src/core/define.test.ts +342 -0
- package/src/core/define.ts +243 -0
- package/src/core/derived.test.ts +381 -0
- package/src/core/derived.ts +339 -0
- package/src/core/effect.test.ts +196 -0
- package/src/core/effect.ts +184 -0
- package/src/core/emitter.test.ts +364 -0
- package/src/core/emitter.ts +392 -0
- package/src/core/equality.test.ts +392 -0
- package/src/core/equality.ts +182 -0
- package/src/core/hook.test.ts +227 -0
- package/src/core/hook.ts +177 -0
- package/src/core/isAtom.ts +27 -0
- package/src/core/isPromiseLike.test.ts +72 -0
- package/src/core/isPromiseLike.ts +16 -0
- package/src/core/onCreateHook.ts +92 -0
- package/src/core/promiseCache.test.ts +239 -0
- package/src/core/promiseCache.ts +279 -0
- package/src/core/scheduleNotifyHook.ts +53 -0
- package/src/core/select.ts +454 -0
- package/src/core/selector.test.ts +257 -0
- package/src/core/types.ts +311 -0
- package/src/core/withUse.test.ts +249 -0
- package/src/core/withUse.ts +56 -0
- package/src/index.test.ts +80 -0
- package/src/index.ts +51 -0
- package/src/react/index.ts +20 -0
- package/src/react/rx.test.tsx +416 -0
- package/src/react/rx.tsx +300 -0
- package/src/react/strictModeTest.tsx +71 -0
- package/src/react/useAction.test.ts +989 -0
- package/src/react/useAction.ts +605 -0
- package/src/react/useStable.test.ts +553 -0
- package/src/react/useStable.ts +288 -0
- package/src/react/useValue.test.ts +182 -0
- package/src/react/useValue.ts +261 -0
- package/tsconfig.json +9 -0
- package/v2.md +725 -0
- package/vite.config.ts +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,1666 @@
|
|
|
1
|
+
# Atomirx
|
|
2
|
+
|
|
3
|
+
**The Official, Opinionated, Batteries-Included Reactive State Management for TypeScript**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/atomirx)
|
|
6
|
+
[](https://www.npmjs.com/package/atomirx)
|
|
7
|
+
[](https://bundlephobia.com/package/atomirx)
|
|
8
|
+
[](https://www.typescriptlang.org/)
|
|
9
|
+
[](https://opensource.org/licenses/MIT)
|
|
10
|
+
|
|
11
|
+
## Purpose
|
|
12
|
+
|
|
13
|
+
The **atomirx** package is intended to be the standard way to write reactive state logic in TypeScript and JavaScript applications. It was originally created to help address three common concerns about state management:
|
|
14
|
+
|
|
15
|
+
- "Setting up reactive state is too complicated"
|
|
16
|
+
- "I have to add a lot of packages to handle async operations"
|
|
17
|
+
- "Fine-grained reactivity requires too much boilerplate"
|
|
18
|
+
|
|
19
|
+
We can't solve every use case, but in the spirit of [`create-react-app`](https://github.com/facebook/create-react-app), we can provide an official, opinionated set of tools that handles the most common use cases and reduces the decisions you need to make.
|
|
20
|
+
|
|
21
|
+
## Table of Contents
|
|
22
|
+
|
|
23
|
+
- [Atomirx](#atomirx)
|
|
24
|
+
- [Purpose](#purpose)
|
|
25
|
+
- [Table of Contents](#table-of-contents)
|
|
26
|
+
- [Installation](#installation)
|
|
27
|
+
- [Using Create React App](#using-create-react-app)
|
|
28
|
+
- [Adding to an Existing Project](#adding-to-an-existing-project)
|
|
29
|
+
- [Why atomirx?](#why-atomirx)
|
|
30
|
+
- [The Problem](#the-problem)
|
|
31
|
+
- [The Solution](#the-solution)
|
|
32
|
+
- [Design Philosophy](#design-philosophy)
|
|
33
|
+
- [What's Included](#whats-included)
|
|
34
|
+
- [Core](#core)
|
|
35
|
+
- [React Bindings (`atomirx/react`)](#react-bindings-atomirxreact)
|
|
36
|
+
- [Getting Started](#getting-started)
|
|
37
|
+
- [Basic Example: Counter](#basic-example-counter)
|
|
38
|
+
- [React Example: Todo App](#react-example-todo-app)
|
|
39
|
+
- [Code Conventions](#code-conventions)
|
|
40
|
+
- [Naming: The `$` Suffix](#naming-the--suffix)
|
|
41
|
+
- [When to Use Each Primitive](#when-to-use-each-primitive)
|
|
42
|
+
- [Atom Storage: Stable Scopes Only](#atom-storage-stable-scopes-only)
|
|
43
|
+
- [Complete Example: Todo App with Async](#complete-example-todo-app-with-async)
|
|
44
|
+
- [Usage Guide](#usage-guide)
|
|
45
|
+
- [Atoms: The Foundation](#atoms-the-foundation)
|
|
46
|
+
- [Creating Atoms](#creating-atoms)
|
|
47
|
+
- [Reading Atom Values](#reading-atom-values)
|
|
48
|
+
- [Updating Atoms](#updating-atoms)
|
|
49
|
+
- [Subscribing to Changes](#subscribing-to-changes)
|
|
50
|
+
- [Complete Atom API](#complete-atom-api)
|
|
51
|
+
- [Derived State: Computed Values](#derived-state-computed-values)
|
|
52
|
+
- [Basic Derived State](#basic-derived-state)
|
|
53
|
+
- [Conditional Dependencies](#conditional-dependencies)
|
|
54
|
+
- [Suspense-Style Getters](#suspense-style-getters)
|
|
55
|
+
- [Derived from Multiple Async Sources](#derived-from-multiple-async-sources)
|
|
56
|
+
- [Effects: Side Effect Management](#effects-side-effect-management)
|
|
57
|
+
- [Basic Effects](#basic-effects)
|
|
58
|
+
- [Effects with Cleanup](#effects-with-cleanup)
|
|
59
|
+
- [Effects with Error Handling](#effects-with-error-handling)
|
|
60
|
+
- [Effects with Multiple Dependencies](#effects-with-multiple-dependencies)
|
|
61
|
+
- [Async Patterns](#async-patterns)
|
|
62
|
+
- [`all()` - Wait for All (like Promise.all)](#all---wait-for-all-like-promiseall)
|
|
63
|
+
- [`any()` - First Ready (like Promise.any)](#any---first-ready-like-promiseany)
|
|
64
|
+
- [`race()` - First Settled (like Promise.race)](#race---first-settled-like-promiserace)
|
|
65
|
+
- [`settled()` - All Results (like Promise.allSettled)](#settled---all-results-like-promiseallsettled)
|
|
66
|
+
- [Async Utility Summary](#async-utility-summary)
|
|
67
|
+
- [Batching Updates](#batching-updates)
|
|
68
|
+
- [Event System](#event-system)
|
|
69
|
+
- [Dependency Injection](#dependency-injection)
|
|
70
|
+
- [Atom Metadata and Middleware](#atom-metadata-and-middleware)
|
|
71
|
+
- [Extending AtomMeta with TypeScript](#extending-atommeta-with-typescript)
|
|
72
|
+
- [Using onCreateHook for Middleware](#using-oncreatehook-for-middleware)
|
|
73
|
+
- [Creating Persisted Atoms](#creating-persisted-atoms)
|
|
74
|
+
- [Multiple Middleware Example](#multiple-middleware-example)
|
|
75
|
+
- [Hook Info Types](#hook-info-types)
|
|
76
|
+
- [React Integration](#react-integration)
|
|
77
|
+
- [useValue Hook](#usevalue-hook)
|
|
78
|
+
- [Custom Equality](#custom-equality)
|
|
79
|
+
- [Reactive Components with rx](#reactive-components-with-rx)
|
|
80
|
+
- [Async Actions with useAction](#async-actions-with-useaction)
|
|
81
|
+
- [Eager Execution](#eager-execution)
|
|
82
|
+
- [useAction API](#useaction-api)
|
|
83
|
+
- [Reference Stability with useStable](#reference-stability-with-usestable)
|
|
84
|
+
- [Suspense Integration](#suspense-integration)
|
|
85
|
+
- [Nested Suspense Boundaries](#nested-suspense-boundaries)
|
|
86
|
+
- [API Reference](#api-reference)
|
|
87
|
+
- [Core API](#core-api)
|
|
88
|
+
- [`atom<T>(initialValue, options?)`](#atomtinitialvalue-options)
|
|
89
|
+
- [`derived<T>(selector)`](#derivedtselector)
|
|
90
|
+
- [`effect(fn, options?)`](#effectfn-options)
|
|
91
|
+
- [`batch(fn)`](#batchfn)
|
|
92
|
+
- [`emitter<T>()`](#emittert)
|
|
93
|
+
- [`define<T>(factory, options?)`](#definetfactory-options)
|
|
94
|
+
- [`isAtom(value)`](#isatomvalue)
|
|
95
|
+
- [SelectContext API](#selectcontext-api)
|
|
96
|
+
- [React API](#react-api)
|
|
97
|
+
- [`useValue`](#usevalue)
|
|
98
|
+
- [`rx`](#rx)
|
|
99
|
+
- [`useAction`](#useaction)
|
|
100
|
+
- [`useStable`](#usestable)
|
|
101
|
+
- [TypeScript Integration](#typescript-integration)
|
|
102
|
+
- [Comparison with Other Libraries](#comparison-with-other-libraries)
|
|
103
|
+
- [When to Use atomirx](#when-to-use-atomirx)
|
|
104
|
+
- [Resources \& Learning](#resources--learning)
|
|
105
|
+
- [Documentation](#documentation)
|
|
106
|
+
- [Examples](#examples)
|
|
107
|
+
- [Community](#community)
|
|
108
|
+
- [License](#license)
|
|
109
|
+
|
|
110
|
+
## Installation
|
|
111
|
+
|
|
112
|
+
### Using Create React App
|
|
113
|
+
|
|
114
|
+
The fastest way to get started is using our official template:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npx create-react-app my-app --template atomirx
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Adding to an Existing Project
|
|
121
|
+
|
|
122
|
+
atomirx is available as a package on NPM for use with a module bundler or in a Node application:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# NPM
|
|
126
|
+
npm install atomirx
|
|
127
|
+
|
|
128
|
+
# Yarn
|
|
129
|
+
yarn add atomirx
|
|
130
|
+
|
|
131
|
+
# PNPM
|
|
132
|
+
pnpm add atomirx
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The package includes precompiled ESM and CommonJS builds, along with TypeScript type definitions.
|
|
136
|
+
|
|
137
|
+
## Why atomirx?
|
|
138
|
+
|
|
139
|
+
### The Problem
|
|
140
|
+
|
|
141
|
+
Traditional state management solutions often require:
|
|
142
|
+
|
|
143
|
+
- **Excessive boilerplate** for simple state updates
|
|
144
|
+
- **Separate packages** for async operations, caching, and derived state
|
|
145
|
+
- **Manual subscription management** leading to memory leaks
|
|
146
|
+
- **Coarse-grained updates** causing unnecessary re-renders
|
|
147
|
+
- **Complex mental models** for understanding data flow
|
|
148
|
+
|
|
149
|
+
### The Solution
|
|
150
|
+
|
|
151
|
+
atomirx provides a **unified, minimal API** that handles all common state management patterns out of the box:
|
|
152
|
+
|
|
153
|
+
| Challenge | atomirx Solution |
|
|
154
|
+
| ----------------- | -------------------------------------------------------------- |
|
|
155
|
+
| Mutable state | `atom()` - single source of truth with automatic subscriptions |
|
|
156
|
+
| Computed values | `derived()` - automatic dependency tracking and memoization |
|
|
157
|
+
| Side effects | `effect()` - declarative effects with cleanup |
|
|
158
|
+
| Async operations | Built-in Promise support with loading/error states |
|
|
159
|
+
| React integration | Suspense-compatible hooks with fine-grained updates |
|
|
160
|
+
| Testing | `define()` - dependency injection with easy mocking |
|
|
161
|
+
|
|
162
|
+
### Design Philosophy
|
|
163
|
+
|
|
164
|
+
atomirx is built on these core principles:
|
|
165
|
+
|
|
166
|
+
1. **Minimal API Surface** - Learn three functions (`atom`, `derived`, `effect`) and you're productive
|
|
167
|
+
2. **Async-First** - Promises are first-class citizens, not an afterthought
|
|
168
|
+
3. **Fine-Grained Reactivity** - Only the code that needs to run, runs
|
|
169
|
+
4. **Type Safety** - Full TypeScript inference without manual type annotations
|
|
170
|
+
5. **Framework Agnostic** - Core library has zero dependencies; React bindings are optional
|
|
171
|
+
6. **Suspense-Native** - Designed from the ground up for React Suspense patterns
|
|
172
|
+
|
|
173
|
+
## What's Included
|
|
174
|
+
|
|
175
|
+
atomirx includes these APIs:
|
|
176
|
+
|
|
177
|
+
### Core
|
|
178
|
+
|
|
179
|
+
- **`atom()`**: Creates mutable reactive state containers with built-in async support
|
|
180
|
+
- **`derived()`**: Creates computed values with automatic dependency tracking
|
|
181
|
+
- **`effect()`**: Runs side effects that automatically re-execute when dependencies change
|
|
182
|
+
- **`batch()`**: Groups multiple updates into a single notification cycle
|
|
183
|
+
- **`define()`**: Creates swappable lazy singletons for dependency injection
|
|
184
|
+
|
|
185
|
+
### React Bindings (`atomirx/react`)
|
|
186
|
+
|
|
187
|
+
- **`useValue()`**: Subscribe to atoms with automatic re-rendering
|
|
188
|
+
- **`rx()`**: Inline reactive components for fine-grained updates
|
|
189
|
+
- **`useAction()`**: Handle async operations with loading/error states
|
|
190
|
+
- **`useStable()`**: Stabilize object/array/callback references
|
|
191
|
+
|
|
192
|
+
## Getting Started
|
|
193
|
+
|
|
194
|
+
### Basic Example: Counter
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
import { atom, derived, effect } from "atomirx";
|
|
198
|
+
|
|
199
|
+
// Step 1: Create an atom (mutable state)
|
|
200
|
+
const count$ = atom(0);
|
|
201
|
+
|
|
202
|
+
// Step 2: Create derived state (computed values)
|
|
203
|
+
const doubled$ = derived(({ get }) => get(count$) * 2);
|
|
204
|
+
const message$ = derived(({ get }) => {
|
|
205
|
+
const count = get(count$);
|
|
206
|
+
return count === 0 ? "Click to start!" : `Count: ${count}`;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Step 3: React to changes with effects
|
|
210
|
+
effect(({ get }) => {
|
|
211
|
+
console.log("Current count:", get(count$));
|
|
212
|
+
console.log("Doubled value:", get(doubled$));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Step 4: Update state
|
|
216
|
+
count$.set(5); // Logs: Current count: 5, Doubled value: 10
|
|
217
|
+
count$.set((n) => n + 1); // Logs: Current count: 6, Doubled value: 12
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### React Example: Todo App
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
import { atom, derived } from "atomirx";
|
|
224
|
+
import { useValue, rx } from "atomirx/react";
|
|
225
|
+
|
|
226
|
+
// Define your state
|
|
227
|
+
interface Todo {
|
|
228
|
+
id: number;
|
|
229
|
+
text: string;
|
|
230
|
+
completed: boolean;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const todos$ = atom<Todo[]>([]);
|
|
234
|
+
const filter$ = atom<"all" | "active" | "completed">("all");
|
|
235
|
+
|
|
236
|
+
// Derive computed state
|
|
237
|
+
const filteredTodos$ = derived(({ get }) => {
|
|
238
|
+
const todos = get(todos$);
|
|
239
|
+
const filter = get(filter$);
|
|
240
|
+
|
|
241
|
+
switch (filter) {
|
|
242
|
+
case "active":
|
|
243
|
+
return todos.filter((t) => !t.completed);
|
|
244
|
+
case "completed":
|
|
245
|
+
return todos.filter((t) => t.completed);
|
|
246
|
+
default:
|
|
247
|
+
return todos;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const stats$ = derived(({ get }) => {
|
|
252
|
+
const todos = get(todos$);
|
|
253
|
+
return {
|
|
254
|
+
total: todos.length,
|
|
255
|
+
completed: todos.filter((t) => t.completed).length,
|
|
256
|
+
remaining: todos.filter((t) => !t.completed).length,
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Actions
|
|
261
|
+
const addTodo = (text: string) => {
|
|
262
|
+
todos$.set((todos) => [...todos, { id: Date.now(), text, completed: false }]);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const toggleTodo = (id: number) => {
|
|
266
|
+
todos$.set((todos) =>
|
|
267
|
+
todos.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
|
|
268
|
+
);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Components
|
|
272
|
+
function TodoList() {
|
|
273
|
+
const todos = useValue(filteredTodos$);
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<ul>
|
|
277
|
+
{todos.map((todo) => (
|
|
278
|
+
<li key={todo.id} onClick={() => toggleTodo(todo.id)}>
|
|
279
|
+
{todo.completed ? "✓" : "○"} {todo.text}
|
|
280
|
+
</li>
|
|
281
|
+
))}
|
|
282
|
+
</ul>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function Stats() {
|
|
287
|
+
// Fine-grained updates: only re-renders when stats change
|
|
288
|
+
return (
|
|
289
|
+
<footer>
|
|
290
|
+
{rx(({ get }) => {
|
|
291
|
+
const { total, completed, remaining } = get(stats$);
|
|
292
|
+
return (
|
|
293
|
+
<span>
|
|
294
|
+
{remaining} of {total} remaining
|
|
295
|
+
</span>
|
|
296
|
+
);
|
|
297
|
+
})}
|
|
298
|
+
</footer>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## Code Conventions
|
|
304
|
+
|
|
305
|
+
Following consistent conventions makes atomirx code more readable and maintainable across your team.
|
|
306
|
+
|
|
307
|
+
### Naming: The `$` Suffix
|
|
308
|
+
|
|
309
|
+
All atoms (both `atom()` and `derived()`) should use the `$` suffix. This convention:
|
|
310
|
+
|
|
311
|
+
- Clearly distinguishes reactive state from regular variables
|
|
312
|
+
- Makes it obvious when you're working with atoms vs plain values
|
|
313
|
+
- Improves code readability at a glance
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
// ✅ Good - clear that these are atoms
|
|
317
|
+
const count$ = atom(0);
|
|
318
|
+
const user$ = atom<User | null>(null);
|
|
319
|
+
const filteredItems$ = derived(({ get }) => /* ... */);
|
|
320
|
+
|
|
321
|
+
// ❌ Avoid - unclear what's reactive
|
|
322
|
+
const count = atom(0);
|
|
323
|
+
const user = atom<User | null>(null);
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### When to Use Each Primitive
|
|
327
|
+
|
|
328
|
+
| Primitive | Purpose | Use When |
|
|
329
|
+
| ----------- | ----------------------- | --------------------------------------------------------------------------- |
|
|
330
|
+
| `atom()` | Store values | You need mutable state (including Promises) |
|
|
331
|
+
| `derived()` | Compute reactive values | You need to transform or combine atom values |
|
|
332
|
+
| `effect()` | Trigger side effects | You need to react to atom changes (sync to external systems, logging, etc.) |
|
|
333
|
+
|
|
334
|
+
### Atom Storage: Stable Scopes Only
|
|
335
|
+
|
|
336
|
+
**Never store atoms in component/local scope.** Atoms created inside React components (even with `useRef`) can lead to:
|
|
337
|
+
|
|
338
|
+
- **Memory leaks** - atoms aren't properly disposed when components unmount
|
|
339
|
+
- **Forgotten disposal** - easy to forget cleanup logic
|
|
340
|
+
- **Multiple instances** - each component render may create new atoms
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
// ❌ BAD - atoms in component scope
|
|
344
|
+
function TodoList() {
|
|
345
|
+
// These atoms are created per component instance!
|
|
346
|
+
const todos$ = useRef(atom(() => fetchTodos())).current;
|
|
347
|
+
const filter$ = useRef(atom("all")).current;
|
|
348
|
+
// Memory leak: atoms not disposed on unmount
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ✅ GOOD - atoms at module scope
|
|
352
|
+
const todos$ = atom(() => fetchTodos());
|
|
353
|
+
const filter$ = atom("all");
|
|
354
|
+
|
|
355
|
+
function TodoList() {
|
|
356
|
+
const todos = useValue(filteredTodos$);
|
|
357
|
+
// ...
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**Use `define()` to organize atoms into modules:**
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
// ✅ BEST - atoms organized in a module with define()
|
|
365
|
+
const TodoModule = define(() => {
|
|
366
|
+
// Atoms are created once, lazily
|
|
367
|
+
const todos$ = atom(() => fetchTodos(), { meta: { key: "todos" } });
|
|
368
|
+
const filter$ = atom<"all" | "active" | "completed">("all");
|
|
369
|
+
|
|
370
|
+
const filteredTodos$ = derived(({ get }) => {
|
|
371
|
+
const filter = get(filter$);
|
|
372
|
+
const todos = get(todos$);
|
|
373
|
+
return filter === "all" ? todos : todos.filter(/* ... */);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
todos$,
|
|
378
|
+
filter$,
|
|
379
|
+
filteredTodos$,
|
|
380
|
+
setFilter: (f: "all" | "active" | "completed") => filter$.set(f),
|
|
381
|
+
refetch: () => todos$.set(fetchTodos()),
|
|
382
|
+
reset: () => todos$.reset(),
|
|
383
|
+
};
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Usage in React
|
|
387
|
+
function TodoList() {
|
|
388
|
+
const { filteredTodos$, setFilter } = TodoModule();
|
|
389
|
+
const todos = useValue(filteredTodos$);
|
|
390
|
+
// ...
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Benefits of `define()`:
|
|
395
|
+
|
|
396
|
+
- **Lazy singleton** - module created on first access
|
|
397
|
+
- **Testable** - use `.override()` to inject mocks
|
|
398
|
+
- **Disposable** - use `.invalidate()` to clean up and recreate
|
|
399
|
+
- **Organized** - group related atoms and actions together
|
|
400
|
+
|
|
401
|
+
**`atom()`** - Store raw values, including Promises:
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
// Synchronous values
|
|
405
|
+
const filter$ = atom("all");
|
|
406
|
+
const count$ = atom(0);
|
|
407
|
+
|
|
408
|
+
// Async values - store the Promise directly
|
|
409
|
+
const todoList$ = atom(() => fetchAllTodos()); // Lazy fetch on creation
|
|
410
|
+
const userData$ = atom(fetchUser(userId)); // Fetch immediately
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
**`derived()`** - Handle reactive/async transformations:
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
// derived() automatically unwraps Promises from atoms
|
|
417
|
+
const filteredTodoList$ = derived(({ get }) => {
|
|
418
|
+
const filter = get(filter$);
|
|
419
|
+
const todoList = get(todoList$); // Promise is unwrapped automatically!
|
|
420
|
+
|
|
421
|
+
switch (filter) {
|
|
422
|
+
case "active":
|
|
423
|
+
return todoList.filter((t) => !t.completed);
|
|
424
|
+
case "completed":
|
|
425
|
+
return todoList.filter((t) => t.completed);
|
|
426
|
+
default:
|
|
427
|
+
return todoList;
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
**`effect()`** - Coordinate updates across multiple atoms:
|
|
433
|
+
|
|
434
|
+
```typescript
|
|
435
|
+
// Sync local state to server when it changes
|
|
436
|
+
effect(({ get, onCleanup }) => {
|
|
437
|
+
const settings = get(settings$);
|
|
438
|
+
|
|
439
|
+
const controller = new AbortController();
|
|
440
|
+
saveSettingsToServer(settings, { signal: controller.signal });
|
|
441
|
+
|
|
442
|
+
onCleanup(() => controller.abort());
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Update multiple atoms based on another atom's change
|
|
446
|
+
effect(({ get }) => {
|
|
447
|
+
const user = get(currentUser$);
|
|
448
|
+
|
|
449
|
+
if (user) {
|
|
450
|
+
// Trigger fetches for user-specific data
|
|
451
|
+
userPosts$.set(fetchUserPosts(user.id));
|
|
452
|
+
userSettings$.set(fetchUserSettings(user.id));
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
### Complete Example: Todo App with Async
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
import { atom, derived } from "atomirx";
|
|
461
|
+
import { useValue, rx } from "atomirx/react";
|
|
462
|
+
import { Suspense } from "react";
|
|
463
|
+
|
|
464
|
+
// Atoms store values (including Promises)
|
|
465
|
+
const filter$ = atom<"all" | "active" | "completed">("all");
|
|
466
|
+
const todoList$ = atom(() => fetchAllTodos()); // Lazy init, re-runs on reset()
|
|
467
|
+
|
|
468
|
+
// Derived handles reactive transformations (auto-unwraps Promises)
|
|
469
|
+
const filteredTodoList$ = derived(({ get }) => {
|
|
470
|
+
const filter = get(filter$);
|
|
471
|
+
const todoList = get(todoList$); // This is the resolved value, not a Promise!
|
|
472
|
+
|
|
473
|
+
switch (filter) {
|
|
474
|
+
case "active": return todoList.filter(t => !t.completed);
|
|
475
|
+
case "completed": return todoList.filter(t => t.completed);
|
|
476
|
+
default: return todoList;
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// In UI - useValue suspends until data is ready
|
|
481
|
+
function TodoList() {
|
|
482
|
+
const filteredTodoList = useValue(filteredTodoList$);
|
|
483
|
+
|
|
484
|
+
return (
|
|
485
|
+
<ul>
|
|
486
|
+
{filteredTodoList.map(todo => (
|
|
487
|
+
<li key={todo.id}>{todo.text}</li>
|
|
488
|
+
))}
|
|
489
|
+
</ul>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Or use rx() for inline reactive rendering
|
|
494
|
+
function App() {
|
|
495
|
+
return (
|
|
496
|
+
<Suspense fallback={<div>Loading todos...</div>}>
|
|
497
|
+
{rx(({ get }) =>
|
|
498
|
+
get(filteredTodoList$).map(todo => <Todo key={todo.id} todo={todo} />)
|
|
499
|
+
)}
|
|
500
|
+
</Suspense>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Refetch todos
|
|
505
|
+
function RefreshButton() {
|
|
506
|
+
return (
|
|
507
|
+
<button onClick={() => todoList$.reset()}>
|
|
508
|
+
Refresh
|
|
509
|
+
</button>
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
## Usage Guide
|
|
515
|
+
|
|
516
|
+
### Atoms: The Foundation
|
|
517
|
+
|
|
518
|
+
Atoms are the building blocks of atomirx. They hold mutable state and automatically notify subscribers when the value changes.
|
|
519
|
+
|
|
520
|
+
#### Creating Atoms
|
|
521
|
+
|
|
522
|
+
```typescript
|
|
523
|
+
import { atom } from "atomirx";
|
|
524
|
+
|
|
525
|
+
// Synchronous atom with initial value
|
|
526
|
+
const count$ = atom(0);
|
|
527
|
+
const user$ = atom<User | null>(null);
|
|
528
|
+
const settings$ = atom({ theme: "dark", language: "en" });
|
|
529
|
+
|
|
530
|
+
// Async atom - automatically tracks loading/error states
|
|
531
|
+
const userData$ = atom(fetchUser(userId));
|
|
532
|
+
|
|
533
|
+
// Lazy initialization - computation deferred until first access
|
|
534
|
+
const expensive$ = atom(() => computeExpensiveValue());
|
|
535
|
+
|
|
536
|
+
// With fallback value during loading/error
|
|
537
|
+
const posts$ = atom(fetchPosts(), { fallback: [] });
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
#### Reading Atom Values
|
|
541
|
+
|
|
542
|
+
```typescript
|
|
543
|
+
import { getAtomState, isPending } from "atomirx";
|
|
544
|
+
|
|
545
|
+
// Direct access (outside reactive context)
|
|
546
|
+
console.log(count$.value); // Current value (T or Promise<T>)
|
|
547
|
+
|
|
548
|
+
// Check atom state
|
|
549
|
+
const state = getAtomState(userData$);
|
|
550
|
+
if (state.status === "loading") {
|
|
551
|
+
console.log("Loading...");
|
|
552
|
+
} else if (state.status === "error") {
|
|
553
|
+
console.log("Error:", state.error);
|
|
554
|
+
} else {
|
|
555
|
+
console.log("User:", state.value);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Quick loading check
|
|
559
|
+
console.log(isPending(userData$.value)); // true while Promise is pending
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
#### Updating Atoms
|
|
563
|
+
|
|
564
|
+
```typescript
|
|
565
|
+
// Direct value
|
|
566
|
+
count$.set(10);
|
|
567
|
+
|
|
568
|
+
// Functional update (receives current value)
|
|
569
|
+
count$.set((current) => current + 1);
|
|
570
|
+
|
|
571
|
+
// Async update
|
|
572
|
+
userData$.set(fetchUser(newUserId));
|
|
573
|
+
|
|
574
|
+
// Reset to initial state
|
|
575
|
+
count$.reset();
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
#### Subscribing to Changes
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
581
|
+
// Subscribe to changes
|
|
582
|
+
const unsubscribe = count$.on((newValue) => {
|
|
583
|
+
console.log("Count changed to:", newValue);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// Await async atoms
|
|
587
|
+
await userData$;
|
|
588
|
+
console.log("User loaded:", userData$.value);
|
|
589
|
+
|
|
590
|
+
// Unsubscribe when done
|
|
591
|
+
unsubscribe();
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
#### Complete Atom API
|
|
595
|
+
|
|
596
|
+
**MutableAtom** (created by `atom()`):
|
|
597
|
+
|
|
598
|
+
| Property/Method | Type | Description |
|
|
599
|
+
| --------------- | ------------ | -------------------------------------------------- |
|
|
600
|
+
| `value` | `T` | Current value (may be a Promise for async atoms) |
|
|
601
|
+
| `set(value)` | `void` | Update with value, Promise, or updater function |
|
|
602
|
+
| `reset()` | `void` | Reset to initial value |
|
|
603
|
+
| `on(listener)` | `() => void` | Subscribe to changes, returns unsubscribe function |
|
|
604
|
+
|
|
605
|
+
**DerivedAtom** (created by `derived()`):
|
|
606
|
+
|
|
607
|
+
| Property/Method | Type | Description |
|
|
608
|
+
| --------------- | ---------------- | ---------------------------------------------- |
|
|
609
|
+
| `value` | `Promise<T>` | Always returns a Promise |
|
|
610
|
+
| `staleValue` | `T \| undefined` | Fallback or last resolved value during loading |
|
|
611
|
+
| `state()` | `AtomState<T>` | Current state (ready/error/loading) |
|
|
612
|
+
| `refresh()` | `void` | Re-run the computation |
|
|
613
|
+
| `on(listener)` | `() => void` | Subscribe to changes, returns unsubscribe |
|
|
614
|
+
|
|
615
|
+
**AtomState** (returned by `state()` or `getAtomState()`):
|
|
616
|
+
|
|
617
|
+
```typescript
|
|
618
|
+
type AtomState<T> =
|
|
619
|
+
| { status: "ready"; value: T }
|
|
620
|
+
| { status: "error"; error: unknown }
|
|
621
|
+
| { status: "loading"; promise: Promise<T> };
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
### Derived State: Computed Values
|
|
625
|
+
|
|
626
|
+
Derived atoms automatically compute values based on other atoms. They track dependencies at runtime and only recompute when those specific dependencies change.
|
|
627
|
+
|
|
628
|
+
#### Basic Derived State
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
import { atom, derived } from "atomirx";
|
|
632
|
+
|
|
633
|
+
const firstName$ = atom("John");
|
|
634
|
+
const lastName$ = atom("Doe");
|
|
635
|
+
|
|
636
|
+
// Derived state with automatic dependency tracking
|
|
637
|
+
const fullName$ = derived(({ get }) => {
|
|
638
|
+
return `${get(firstName$)} ${get(lastName$)}`;
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// Derived atoms always return Promise<T> for .value
|
|
642
|
+
await fullName$.value; // "John Doe"
|
|
643
|
+
|
|
644
|
+
// Or use staleValue for synchronous access (after first resolution)
|
|
645
|
+
fullName$.staleValue; // "John Doe" (or undefined before first resolution)
|
|
646
|
+
|
|
647
|
+
// Check state
|
|
648
|
+
fullName$.state(); // { status: "ready", value: "John Doe" }
|
|
649
|
+
|
|
650
|
+
firstName$.set("Jane");
|
|
651
|
+
await fullName$.value; // "Jane Doe"
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
#### Conditional Dependencies
|
|
655
|
+
|
|
656
|
+
One of atomirx's most powerful features is **conditional dependency tracking**. Dependencies are tracked based on actual runtime access, not static analysis:
|
|
657
|
+
|
|
658
|
+
```typescript
|
|
659
|
+
const showDetails$ = atom(false);
|
|
660
|
+
const summary$ = atom("Brief overview");
|
|
661
|
+
const details$ = atom("Detailed information...");
|
|
662
|
+
|
|
663
|
+
const content$ = derived(({ get }) => {
|
|
664
|
+
// Only tracks showDetails$ initially
|
|
665
|
+
if (get(showDetails$)) {
|
|
666
|
+
// details$ becomes a dependency only when showDetails$ is true
|
|
667
|
+
return get(details$);
|
|
668
|
+
}
|
|
669
|
+
return get(summary$);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// When showDetails$ is false:
|
|
673
|
+
// - Changes to details$ do NOT trigger recomputation
|
|
674
|
+
// - Only changes to showDetails$ or summary$ trigger recomputation
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
#### Suspense-Style Getters
|
|
678
|
+
|
|
679
|
+
The `get()` function follows React Suspense semantics for async atoms:
|
|
680
|
+
|
|
681
|
+
| Atom State | `get()` Behavior |
|
|
682
|
+
| ---------- | ----------------------------------------------- |
|
|
683
|
+
| Loading | Throws the Promise (caught by derived/Suspense) |
|
|
684
|
+
| Error | Throws the error |
|
|
685
|
+
| Ready | Returns the value |
|
|
686
|
+
|
|
687
|
+
```typescript
|
|
688
|
+
const user$ = atom(fetchUser());
|
|
689
|
+
|
|
690
|
+
const userName$ = derived(({ get }) => {
|
|
691
|
+
// Automatically handles loading/error states
|
|
692
|
+
const user = get(user$);
|
|
693
|
+
return user.name;
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// Check state
|
|
697
|
+
const state = userName$.state();
|
|
698
|
+
if (state.status === "loading") {
|
|
699
|
+
console.log("Loading...");
|
|
700
|
+
} else if (state.status === "error") {
|
|
701
|
+
console.log("Error:", state.error);
|
|
702
|
+
} else {
|
|
703
|
+
console.log("User name:", state.value);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Or use staleValue with a fallback
|
|
707
|
+
const userName = derived(({ get }) => get(user$).name, { fallback: "Guest" });
|
|
708
|
+
userName.staleValue; // "Guest" during loading, then actual name
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
#### Derived from Multiple Async Sources
|
|
712
|
+
|
|
713
|
+
```typescript
|
|
714
|
+
const user$ = atom(fetchUser());
|
|
715
|
+
const posts$ = atom(fetchPosts());
|
|
716
|
+
|
|
717
|
+
const dashboard$ = derived(({ get }) => {
|
|
718
|
+
const user = get(user$); // Suspends if loading
|
|
719
|
+
const posts = get(posts$); // Suspends if loading
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
userName: user.name,
|
|
723
|
+
postCount: posts.length,
|
|
724
|
+
};
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// Check state
|
|
728
|
+
const state = dashboard$.state();
|
|
729
|
+
// state.status is "loading" until BOTH user$ and posts$ resolve
|
|
730
|
+
|
|
731
|
+
// Or use all() for explicit parallel loading
|
|
732
|
+
const dashboard2$ = derived(({ all }) => {
|
|
733
|
+
const [user, posts] = all(user$, posts$);
|
|
734
|
+
return { userName: user.name, postCount: posts.length };
|
|
735
|
+
});
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
### Effects: Side Effect Management
|
|
739
|
+
|
|
740
|
+
Effects run side effects whenever their dependencies change. They use the same reactive context as `derived()`.
|
|
741
|
+
|
|
742
|
+
#### Basic Effects
|
|
743
|
+
|
|
744
|
+
```typescript
|
|
745
|
+
import { atom, effect } from "atomirx";
|
|
746
|
+
|
|
747
|
+
const count$ = atom(0);
|
|
748
|
+
|
|
749
|
+
// Effect runs immediately and on every change
|
|
750
|
+
const dispose = effect(({ get }) => {
|
|
751
|
+
console.log("Count is now:", get(count$));
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
count$.set(5); // Logs: "Count is now: 5"
|
|
755
|
+
|
|
756
|
+
// Clean up when done
|
|
757
|
+
dispose();
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
#### Effects with Cleanup
|
|
761
|
+
|
|
762
|
+
Use `onCleanup()` to register cleanup functions that run before the next execution or on dispose:
|
|
763
|
+
|
|
764
|
+
```typescript
|
|
765
|
+
const interval$ = atom(1000);
|
|
766
|
+
|
|
767
|
+
const dispose = effect(({ get, onCleanup }) => {
|
|
768
|
+
const ms = get(interval$);
|
|
769
|
+
const id = setInterval(() => console.log("tick"), ms);
|
|
770
|
+
|
|
771
|
+
// Cleanup runs before next execution or on dispose
|
|
772
|
+
onCleanup(() => clearInterval(id));
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
interval$.set(500); // Clears old interval, starts new one
|
|
776
|
+
dispose(); // Clears interval completely
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
#### Effects with Error Handling
|
|
780
|
+
|
|
781
|
+
Use `onError()` to handle errors within the effect:
|
|
782
|
+
|
|
783
|
+
```typescript
|
|
784
|
+
const dispose = effect(({ get, onError }) => {
|
|
785
|
+
onError((e) => console.error("Effect failed:", e));
|
|
786
|
+
|
|
787
|
+
const data = get(dataAtom$);
|
|
788
|
+
riskyOperation(data);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// Or use options.onError for unhandled errors
|
|
792
|
+
const dispose2 = effect(
|
|
793
|
+
({ get }) => {
|
|
794
|
+
riskyOperation(get(dataAtom$));
|
|
795
|
+
},
|
|
796
|
+
{ onError: (e) => console.error("Unhandled:", e) }
|
|
797
|
+
);
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
#### Effects with Multiple Dependencies
|
|
801
|
+
|
|
802
|
+
```typescript
|
|
803
|
+
const user$ = atom<User | null>(null);
|
|
804
|
+
const settings$ = atom({ notifications: true });
|
|
805
|
+
|
|
806
|
+
effect(({ get }) => {
|
|
807
|
+
const user = get(user$);
|
|
808
|
+
const settings = get(settings$);
|
|
809
|
+
|
|
810
|
+
if (user && settings.notifications) {
|
|
811
|
+
analytics.identify(user.id);
|
|
812
|
+
notifications.subscribe(user.id);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
### Async Patterns
|
|
818
|
+
|
|
819
|
+
atomirx provides powerful utilities for working with multiple async atoms through the `SelectContext`.
|
|
820
|
+
|
|
821
|
+
#### `all()` - Wait for All (like Promise.all)
|
|
822
|
+
|
|
823
|
+
```typescript
|
|
824
|
+
const user$ = atom(fetchUser());
|
|
825
|
+
const posts$ = atom(fetchPosts());
|
|
826
|
+
const comments$ = atom(fetchComments());
|
|
827
|
+
|
|
828
|
+
const dashboard$ = derived(({ all }) => {
|
|
829
|
+
// Suspends until ALL atoms resolve (variadic args)
|
|
830
|
+
const [user, posts, comments] = all(user$, posts$, comments$);
|
|
831
|
+
|
|
832
|
+
return { user, posts, comments };
|
|
833
|
+
});
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
#### `any()` - First Ready (like Promise.any)
|
|
837
|
+
|
|
838
|
+
```typescript
|
|
839
|
+
const primaryApi$ = atom(fetchFromPrimary());
|
|
840
|
+
const fallbackApi$ = atom(fetchFromFallback());
|
|
841
|
+
|
|
842
|
+
const data$ = derived(({ any }) => {
|
|
843
|
+
// Returns first successfully resolved value (variadic args)
|
|
844
|
+
return any(primaryApi$, fallbackApi$);
|
|
845
|
+
});
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
#### `race()` - First Settled (like Promise.race)
|
|
849
|
+
|
|
850
|
+
```typescript
|
|
851
|
+
const cache$ = atom(checkCache());
|
|
852
|
+
const api$ = atom(fetchFromApi());
|
|
853
|
+
|
|
854
|
+
const data$ = derived(({ race }) => {
|
|
855
|
+
// Returns first settled (ready OR error) (variadic args)
|
|
856
|
+
return race(cache$, api$);
|
|
857
|
+
});
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
#### `settled()` - All Results (like Promise.allSettled)
|
|
861
|
+
|
|
862
|
+
```typescript
|
|
863
|
+
const user$ = atom(fetchUser());
|
|
864
|
+
const posts$ = atom(fetchPosts());
|
|
865
|
+
|
|
866
|
+
const results$ = derived(({ settled }) => {
|
|
867
|
+
// Returns status for each atom (variadic args)
|
|
868
|
+
const [userResult, postsResult] = settled(user$, posts$);
|
|
869
|
+
|
|
870
|
+
return {
|
|
871
|
+
user: userResult.status === "ready" ? userResult.value : null,
|
|
872
|
+
posts: postsResult.status === "ready" ? postsResult.value : [],
|
|
873
|
+
hasErrors: userResult.status === "error" || postsResult.status === "error",
|
|
874
|
+
};
|
|
875
|
+
});
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
#### Async Utility Summary
|
|
879
|
+
|
|
880
|
+
| Utility | Input | Output | Behavior |
|
|
881
|
+
| ----------- | -------------- | ---------------------- | ---------------------------------- |
|
|
882
|
+
| `all()` | Variadic atoms | Array of values | Suspends until all ready |
|
|
883
|
+
| `any()` | Variadic atoms | First ready value | First to resolve wins |
|
|
884
|
+
| `race()` | Variadic atoms | First settled value | First to settle (ready/error) wins |
|
|
885
|
+
| `settled()` | Variadic atoms | Array of SettledResult | Suspends until all settled |
|
|
886
|
+
|
|
887
|
+
**SettledResult type:**
|
|
888
|
+
|
|
889
|
+
```typescript
|
|
890
|
+
type SettledResult<T> =
|
|
891
|
+
| { status: "ready"; value: T }
|
|
892
|
+
| { status: "error"; error: unknown };
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
### Batching Updates
|
|
896
|
+
|
|
897
|
+
When updating multiple atoms, use `batch()` to combine notifications:
|
|
898
|
+
|
|
899
|
+
```typescript
|
|
900
|
+
import { atom, batch } from "atomirx";
|
|
901
|
+
|
|
902
|
+
const firstName$ = atom("John");
|
|
903
|
+
const lastName$ = atom("Doe");
|
|
904
|
+
const age$ = atom(30);
|
|
905
|
+
|
|
906
|
+
// Without batch: 3 separate notifications
|
|
907
|
+
firstName$.set("Jane");
|
|
908
|
+
lastName$.set("Smith");
|
|
909
|
+
age$.set(25);
|
|
910
|
+
|
|
911
|
+
// With batch: 1 notification with final state
|
|
912
|
+
batch(() => {
|
|
913
|
+
firstName$.set("Jane");
|
|
914
|
+
lastName$.set("Smith");
|
|
915
|
+
age$.set(25);
|
|
916
|
+
});
|
|
917
|
+
```
|
|
918
|
+
|
|
919
|
+
### Event System
|
|
920
|
+
|
|
921
|
+
The `emitter()` function provides a lightweight pub/sub system:
|
|
922
|
+
|
|
923
|
+
```typescript
|
|
924
|
+
import { emitter } from "atomirx";
|
|
925
|
+
|
|
926
|
+
// Create typed emitter
|
|
927
|
+
const userEvents = emitter<{ type: "login" | "logout"; userId: string }>();
|
|
928
|
+
|
|
929
|
+
// Subscribe
|
|
930
|
+
const unsubscribe = userEvents.on((event) => {
|
|
931
|
+
console.log(`User ${event.userId} ${event.type}`);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// Emit events
|
|
935
|
+
userEvents.emit({ type: "login", userId: "123" });
|
|
936
|
+
|
|
937
|
+
// Settle pattern - late subscribers receive the settled value
|
|
938
|
+
const appReady = emitter<Config>();
|
|
939
|
+
appReady.settle(config); // All current AND future subscribers receive config
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
### Dependency Injection
|
|
943
|
+
|
|
944
|
+
The `define()` function creates swappable lazy singletons, perfect for testing:
|
|
945
|
+
|
|
946
|
+
```typescript
|
|
947
|
+
import { define, atom } from "atomirx";
|
|
948
|
+
|
|
949
|
+
// Define a store factory
|
|
950
|
+
const counterStore = define(() => {
|
|
951
|
+
const count$ = atom(0);
|
|
952
|
+
|
|
953
|
+
return {
|
|
954
|
+
count$,
|
|
955
|
+
increment: () => count$.set((c) => c + 1),
|
|
956
|
+
decrement: () => count$.set((c) => c - 1),
|
|
957
|
+
reset: () => count$.reset(),
|
|
958
|
+
};
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
// Usage - lazy singleton (same instance everywhere)
|
|
962
|
+
const store = counterStore();
|
|
963
|
+
store.increment();
|
|
964
|
+
|
|
965
|
+
// Testing - override the factory
|
|
966
|
+
counterStore.override(() => ({
|
|
967
|
+
count$: atom(999),
|
|
968
|
+
increment: vi.fn(),
|
|
969
|
+
decrement: vi.fn(),
|
|
970
|
+
reset: vi.fn(),
|
|
971
|
+
}));
|
|
972
|
+
|
|
973
|
+
// Reset to original implementation
|
|
974
|
+
counterStore.reset();
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
### Atom Metadata and Middleware
|
|
978
|
+
|
|
979
|
+
atomirx supports custom metadata on atoms via the `meta` option. Combined with `onCreateHook`, you can implement cross-cutting concerns like persistence, logging, or validation.
|
|
980
|
+
|
|
981
|
+
#### Extending AtomMeta with TypeScript
|
|
982
|
+
|
|
983
|
+
Use TypeScript's module augmentation to add custom properties to `AtomMeta`:
|
|
984
|
+
|
|
985
|
+
```typescript
|
|
986
|
+
// Extend the meta interfaces with your custom properties
|
|
987
|
+
declare module "atomirx" {
|
|
988
|
+
// MutableAtomMeta - for atom() specific options
|
|
989
|
+
interface MutableAtomMeta {
|
|
990
|
+
/** Whether the atom should be persisted to localStorage */
|
|
991
|
+
persisted?: boolean;
|
|
992
|
+
/**
|
|
993
|
+
* Custom validation function.
|
|
994
|
+
* Return true to allow the update, false to reject it.
|
|
995
|
+
*/
|
|
996
|
+
validate?: (value: unknown) => boolean;
|
|
997
|
+
/** Optional error handler for validation failures */
|
|
998
|
+
onValidationError?: (value: unknown, key?: string) => void;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// DerivedAtomMeta - for derived() specific options
|
|
1002
|
+
interface DerivedAtomMeta {
|
|
1003
|
+
/** Custom cache key for memoization */
|
|
1004
|
+
cacheKey?: string;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// AtomMeta - base type, shared by both (key, etc.)
|
|
1008
|
+
// interface AtomMeta { ... }
|
|
1009
|
+
}
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
#### Using onCreateHook for Middleware
|
|
1013
|
+
|
|
1014
|
+
The `onCreateHook` fires whenever an atom or module is created. Use the reducer pattern to compose multiple middlewares:
|
|
1015
|
+
|
|
1016
|
+
```typescript
|
|
1017
|
+
import { onCreateHook } from "atomirx";
|
|
1018
|
+
|
|
1019
|
+
// Persistence middleware
|
|
1020
|
+
onCreateHook.override((prev) => (info) => {
|
|
1021
|
+
// Call previous middleware first (composition)
|
|
1022
|
+
prev?.(info);
|
|
1023
|
+
|
|
1024
|
+
// Only handle mutable atoms with persisted flag
|
|
1025
|
+
if (info.type === "mutable" && info.meta?.persisted && info.meta?.key) {
|
|
1026
|
+
const storageKey = `my-app-${info.meta.key}`;
|
|
1027
|
+
|
|
1028
|
+
// Restore from localStorage on creation (if not dirty)
|
|
1029
|
+
if (!info.atom.dirty()) {
|
|
1030
|
+
const stored = localStorage.getItem(storageKey);
|
|
1031
|
+
if (stored) {
|
|
1032
|
+
try {
|
|
1033
|
+
info.atom.set(JSON.parse(stored));
|
|
1034
|
+
} catch {
|
|
1035
|
+
// Invalid JSON, ignore
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Save to localStorage on every change
|
|
1041
|
+
info.atom.on(() => {
|
|
1042
|
+
localStorage.setItem(storageKey, JSON.stringify(info.atom.value));
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
#### Creating Persisted Atoms
|
|
1049
|
+
|
|
1050
|
+
Now atoms with `persisted: true` automatically sync with localStorage:
|
|
1051
|
+
|
|
1052
|
+
```typescript
|
|
1053
|
+
// This atom will persist across page reloads
|
|
1054
|
+
const settings$ = atom(
|
|
1055
|
+
{ theme: "dark", language: "en" },
|
|
1056
|
+
{ meta: { key: "settings", persisted: true } }
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
// Changes are automatically saved
|
|
1060
|
+
settings$.set({ theme: "light", language: "en" });
|
|
1061
|
+
|
|
1062
|
+
// On next page load, value is restored from localStorage
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
#### Multiple Middleware Example
|
|
1066
|
+
|
|
1067
|
+
Compose multiple middlewares using the reducer pattern:
|
|
1068
|
+
|
|
1069
|
+
```typescript
|
|
1070
|
+
// Logging middleware
|
|
1071
|
+
onCreateHook.override((prev) => (info) => {
|
|
1072
|
+
prev?.(info);
|
|
1073
|
+
console.log(
|
|
1074
|
+
`[atomirx] Created ${info.type}: ${info.meta?.key ?? "anonymous"}`
|
|
1075
|
+
);
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
// Validation middleware - wraps set() to validate before applying
|
|
1079
|
+
onCreateHook.override((prev) => (info) => {
|
|
1080
|
+
prev?.(info);
|
|
1081
|
+
|
|
1082
|
+
if (info.type === "mutable" && info.meta?.validate) {
|
|
1083
|
+
const validate = info.meta.validate;
|
|
1084
|
+
const originalSet = info.atom.set.bind(info.atom);
|
|
1085
|
+
|
|
1086
|
+
// Wrap set() with validation
|
|
1087
|
+
info.atom.set = (valueOrReducer) => {
|
|
1088
|
+
// Resolve the next value (handle both direct value and reducer)
|
|
1089
|
+
const nextValue =
|
|
1090
|
+
typeof valueOrReducer === "function"
|
|
1091
|
+
? (valueOrReducer as (prev: unknown) => unknown)(info.atom.value)
|
|
1092
|
+
: valueOrReducer;
|
|
1093
|
+
|
|
1094
|
+
// Validate before applying
|
|
1095
|
+
if (!validate(nextValue)) {
|
|
1096
|
+
console.warn(
|
|
1097
|
+
`[atomirx] Validation failed for ${info.meta?.key}`,
|
|
1098
|
+
nextValue
|
|
1099
|
+
);
|
|
1100
|
+
return; // Reject the update
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
originalSet(valueOrReducer);
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// Usage: atom with validation
|
|
1109
|
+
const age$ = atom(25, {
|
|
1110
|
+
meta: {
|
|
1111
|
+
key: "age",
|
|
1112
|
+
validate: (value) =>
|
|
1113
|
+
typeof value === "number" && value >= 0 && value <= 150,
|
|
1114
|
+
},
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
age$.set(30); // OK
|
|
1118
|
+
age$.set(-5); // Rejected: "Validation failed for age"
|
|
1119
|
+
age$.set(200); // Rejected: "Validation failed for age"
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
> **Note:** This validation approach intercepts `set()` at runtime. For compile-time type safety, use TypeScript's type system. This pattern is useful for runtime constraints like ranges, formats, or business rules that can't be expressed in types.
|
|
1123
|
+
|
|
1124
|
+
```typescript
|
|
1125
|
+
// DevTools middleware
|
|
1126
|
+
onCreateHook.override((prev) => (info) => {
|
|
1127
|
+
prev?.(info);
|
|
1128
|
+
|
|
1129
|
+
if (info.type === "mutable" || info.type === "derived") {
|
|
1130
|
+
// Register with your devtools
|
|
1131
|
+
window.__ATOMIRX_DEVTOOLS__?.register(info);
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
#### Hook Info Types
|
|
1137
|
+
|
|
1138
|
+
The `onCreateHook` receives different info objects based on what's being created:
|
|
1139
|
+
|
|
1140
|
+
```typescript
|
|
1141
|
+
// Mutable atom
|
|
1142
|
+
interface MutableAtomCreateInfo {
|
|
1143
|
+
type: "mutable";
|
|
1144
|
+
key: string | undefined;
|
|
1145
|
+
meta: AtomMeta | undefined;
|
|
1146
|
+
atom: MutableAtom<unknown>;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Derived atom
|
|
1150
|
+
interface DerivedAtomCreateInfo {
|
|
1151
|
+
type: "derived";
|
|
1152
|
+
key: string | undefined;
|
|
1153
|
+
meta: AtomMeta | undefined;
|
|
1154
|
+
atom: DerivedAtom<unknown, boolean>;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Module (from define())
|
|
1158
|
+
interface ModuleCreateInfo {
|
|
1159
|
+
type: "module";
|
|
1160
|
+
key: string | undefined;
|
|
1161
|
+
meta: ModuleMeta | undefined;
|
|
1162
|
+
module: unknown;
|
|
1163
|
+
}
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
## React Integration
|
|
1167
|
+
|
|
1168
|
+
atomirx provides first-class React integration through the `atomirx/react` package.
|
|
1169
|
+
|
|
1170
|
+
### useValue Hook
|
|
1171
|
+
|
|
1172
|
+
Subscribe to atom values with automatic re-rendering:
|
|
1173
|
+
|
|
1174
|
+
```tsx
|
|
1175
|
+
import { useValue } from "atomirx/react";
|
|
1176
|
+
import { atom } from "atomirx";
|
|
1177
|
+
|
|
1178
|
+
const count$ = atom(0);
|
|
1179
|
+
const user$ = atom<User | null>(null);
|
|
1180
|
+
|
|
1181
|
+
function Counter() {
|
|
1182
|
+
// Shorthand: pass atom directly
|
|
1183
|
+
const count = useValue(count$);
|
|
1184
|
+
|
|
1185
|
+
// Context selector: compute derived value
|
|
1186
|
+
const doubled = useValue(({ get }) => get(count$) * 2);
|
|
1187
|
+
|
|
1188
|
+
// Multiple atoms
|
|
1189
|
+
const display = useValue(({ get }) => {
|
|
1190
|
+
const count = get(count$);
|
|
1191
|
+
const user = get(user$);
|
|
1192
|
+
return user ? `${user.name}: ${count}` : `Anonymous: ${count}`;
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
return <div>{display}</div>;
|
|
1196
|
+
}
|
|
1197
|
+
```
|
|
1198
|
+
|
|
1199
|
+
#### Custom Equality
|
|
1200
|
+
|
|
1201
|
+
```tsx
|
|
1202
|
+
// Only re-render when specific fields change
|
|
1203
|
+
const userName = useValue(
|
|
1204
|
+
({ get }) => get(user$)?.name,
|
|
1205
|
+
(prev, next) => prev === next
|
|
1206
|
+
);
|
|
1207
|
+
```
|
|
1208
|
+
|
|
1209
|
+
### Reactive Components with rx
|
|
1210
|
+
|
|
1211
|
+
The `rx()` function creates inline reactive components for fine-grained updates:
|
|
1212
|
+
|
|
1213
|
+
```tsx
|
|
1214
|
+
import { rx } from "atomirx/react";
|
|
1215
|
+
|
|
1216
|
+
function Dashboard() {
|
|
1217
|
+
return (
|
|
1218
|
+
<div>
|
|
1219
|
+
{/* Only this span re-renders when count$ changes */}
|
|
1220
|
+
<span>Count: {rx(count$)}</span>
|
|
1221
|
+
|
|
1222
|
+
{/* Derived value */}
|
|
1223
|
+
<span>Doubled: {rx(({ get }) => get(count$) * 2)}</span>
|
|
1224
|
+
|
|
1225
|
+
{/* Complex rendering */}
|
|
1226
|
+
{rx(({ get }) => {
|
|
1227
|
+
const user = get(user$);
|
|
1228
|
+
return user ? <UserCard user={user} /> : <LoginPrompt />;
|
|
1229
|
+
})}
|
|
1230
|
+
|
|
1231
|
+
{/* Async with utilities */}
|
|
1232
|
+
{rx(({ all }) => {
|
|
1233
|
+
const [user, posts] = all(user$, posts$);
|
|
1234
|
+
return <Feed user={user} posts={posts} />;
|
|
1235
|
+
})}
|
|
1236
|
+
</div>
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
**Key benefit**: The parent component doesn't re-render when atoms change - only the `rx` components do.
|
|
1242
|
+
|
|
1243
|
+
### Async Actions with useAction
|
|
1244
|
+
|
|
1245
|
+
Handle async operations with built-in loading/error states:
|
|
1246
|
+
|
|
1247
|
+
```tsx
|
|
1248
|
+
import { useAction } from "atomirx/react";
|
|
1249
|
+
|
|
1250
|
+
function UserProfile({ userId }: { userId: string }) {
|
|
1251
|
+
const saveUser = useAction(async ({ signal }, data: UserData) => {
|
|
1252
|
+
const response = await fetch(`/api/users/${userId}`, {
|
|
1253
|
+
method: "PUT",
|
|
1254
|
+
body: JSON.stringify(data),
|
|
1255
|
+
signal, // Automatic abort on unmount or re-execution
|
|
1256
|
+
});
|
|
1257
|
+
return response.json();
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
return (
|
|
1261
|
+
<form
|
|
1262
|
+
onSubmit={(e) => {
|
|
1263
|
+
e.preventDefault();
|
|
1264
|
+
saveUser(formData);
|
|
1265
|
+
}}
|
|
1266
|
+
>
|
|
1267
|
+
{saveUser.status === "loading" && <Spinner />}
|
|
1268
|
+
{saveUser.status === "error" && <Error message={saveUser.error} />}
|
|
1269
|
+
{saveUser.status === "success" && <Success data={saveUser.result} />}
|
|
1270
|
+
|
|
1271
|
+
<button disabled={saveUser.status === "loading"}>Save</button>
|
|
1272
|
+
</form>
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
#### Eager Execution
|
|
1278
|
+
|
|
1279
|
+
```tsx
|
|
1280
|
+
// Execute immediately on mount
|
|
1281
|
+
const fetchUser = useAction(
|
|
1282
|
+
async ({ signal }) => fetchUserApi(userId, { signal }),
|
|
1283
|
+
{ lazy: false, deps: [userId] }
|
|
1284
|
+
);
|
|
1285
|
+
|
|
1286
|
+
// Re-execute when atom changes
|
|
1287
|
+
const fetchPosts = useAction(
|
|
1288
|
+
async ({ signal }) => fetchPostsApi(filter$.value, { signal }),
|
|
1289
|
+
{ lazy: false, deps: [filter$] }
|
|
1290
|
+
);
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
#### useAction API
|
|
1294
|
+
|
|
1295
|
+
| Property/Method | Type | Description |
|
|
1296
|
+
| --------------- | --------------------------------------------- | ---------------------------- |
|
|
1297
|
+
| `status` | `'idle' \| 'loading' \| 'success' \| 'error'` | Current state |
|
|
1298
|
+
| `result` | `T \| undefined` | Result value when successful |
|
|
1299
|
+
| `error` | `unknown` | Error when failed |
|
|
1300
|
+
| `abort()` | `() => void` | Cancel current request |
|
|
1301
|
+
| `reset()` | `() => void` | Reset to idle state |
|
|
1302
|
+
|
|
1303
|
+
### Reference Stability with useStable
|
|
1304
|
+
|
|
1305
|
+
Prevent unnecessary re-renders by stabilizing references:
|
|
1306
|
+
|
|
1307
|
+
```tsx
|
|
1308
|
+
import { useStable } from "atomirx/react";
|
|
1309
|
+
|
|
1310
|
+
function SearchResults({ query, filters }: Props) {
|
|
1311
|
+
const stable = useStable({
|
|
1312
|
+
// Object - stable if shallow equal
|
|
1313
|
+
searchParams: { query, ...filters },
|
|
1314
|
+
|
|
1315
|
+
// Array - stable if items are reference-equal
|
|
1316
|
+
selectedIds: [1, 2, 3],
|
|
1317
|
+
|
|
1318
|
+
// Function - reference never changes
|
|
1319
|
+
onSelect: (id: number) => {
|
|
1320
|
+
console.log("Selected:", id);
|
|
1321
|
+
},
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
// Safe to use in dependency arrays
|
|
1325
|
+
useEffect(() => {
|
|
1326
|
+
performSearch(stable.searchParams);
|
|
1327
|
+
}, [stable.searchParams]);
|
|
1328
|
+
|
|
1329
|
+
return (
|
|
1330
|
+
<MemoizedList params={stable.searchParams} onSelect={stable.onSelect} />
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
```
|
|
1334
|
+
|
|
1335
|
+
### Suspense Integration
|
|
1336
|
+
|
|
1337
|
+
atomirx is designed to work seamlessly with React Suspense:
|
|
1338
|
+
|
|
1339
|
+
```tsx
|
|
1340
|
+
import { Suspense } from "react";
|
|
1341
|
+
import { ErrorBoundary } from "react-error-boundary";
|
|
1342
|
+
import { atom } from "atomirx";
|
|
1343
|
+
import { useValue } from "atomirx/react";
|
|
1344
|
+
|
|
1345
|
+
const user$ = atom(fetchUser());
|
|
1346
|
+
|
|
1347
|
+
function UserProfile() {
|
|
1348
|
+
// Suspends until user$ resolves
|
|
1349
|
+
const user = useValue(user$);
|
|
1350
|
+
return <div>{user.name}</div>;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function App() {
|
|
1354
|
+
return (
|
|
1355
|
+
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
|
1356
|
+
<Suspense fallback={<div>Loading...</div>}>
|
|
1357
|
+
<UserProfile />
|
|
1358
|
+
</Suspense>
|
|
1359
|
+
</ErrorBoundary>
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
```
|
|
1363
|
+
|
|
1364
|
+
#### Nested Suspense Boundaries
|
|
1365
|
+
|
|
1366
|
+
```tsx
|
|
1367
|
+
function Dashboard() {
|
|
1368
|
+
return (
|
|
1369
|
+
<div>
|
|
1370
|
+
<Suspense fallback={<HeaderSkeleton />}>
|
|
1371
|
+
<Header /> {/* Depends on user$ */}
|
|
1372
|
+
</Suspense>
|
|
1373
|
+
|
|
1374
|
+
<Suspense fallback={<FeedSkeleton />}>
|
|
1375
|
+
<Feed /> {/* Depends on posts$ */}
|
|
1376
|
+
</Suspense>
|
|
1377
|
+
|
|
1378
|
+
<Suspense fallback={<SidebarSkeleton />}>
|
|
1379
|
+
<Sidebar /> {/* Depends on recommendations$ */}
|
|
1380
|
+
</Suspense>
|
|
1381
|
+
</div>
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
```
|
|
1385
|
+
|
|
1386
|
+
## API Reference
|
|
1387
|
+
|
|
1388
|
+
### Core API
|
|
1389
|
+
|
|
1390
|
+
#### `atom<T>(initialValue, options?)`
|
|
1391
|
+
|
|
1392
|
+
Creates a mutable reactive atom.
|
|
1393
|
+
|
|
1394
|
+
```typescript
|
|
1395
|
+
function atom<T>(
|
|
1396
|
+
initialValue: T | Promise<T> | (() => T | Promise<T>),
|
|
1397
|
+
options?: { fallback?: T }
|
|
1398
|
+
): MutableAtom<T>;
|
|
1399
|
+
```
|
|
1400
|
+
|
|
1401
|
+
#### `derived<T>(selector)`
|
|
1402
|
+
|
|
1403
|
+
Creates a read-only derived atom.
|
|
1404
|
+
|
|
1405
|
+
```typescript
|
|
1406
|
+
function derived<T>(selector: (context: SelectContext) => T): Atom<T>;
|
|
1407
|
+
|
|
1408
|
+
// Legacy array API (backward compatible)
|
|
1409
|
+
function derived<T, S>(source: Atom<S>, selector: (get: () => S) => T): Atom<T>;
|
|
1410
|
+
|
|
1411
|
+
function derived<T, S extends readonly Atom<any>[]>(
|
|
1412
|
+
sources: S,
|
|
1413
|
+
selector: (...getters: GetterTuple<S>) => T
|
|
1414
|
+
): Atom<T>;
|
|
1415
|
+
```
|
|
1416
|
+
|
|
1417
|
+
#### `effect(fn, options?)`
|
|
1418
|
+
|
|
1419
|
+
Creates a side effect that runs when dependencies change.
|
|
1420
|
+
|
|
1421
|
+
```typescript
|
|
1422
|
+
interface EffectContext extends SelectContext {
|
|
1423
|
+
onCleanup: (cleanup: VoidFunction) => void;
|
|
1424
|
+
onError: (handler: (error: unknown) => void) => void;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function effect(
|
|
1428
|
+
fn: (context: EffectContext) => void,
|
|
1429
|
+
options?: { onError?: (error: Error) => void }
|
|
1430
|
+
): () => void; // Returns dispose function
|
|
1431
|
+
```
|
|
1432
|
+
|
|
1433
|
+
#### `batch(fn)`
|
|
1434
|
+
|
|
1435
|
+
Batches multiple updates into a single notification.
|
|
1436
|
+
|
|
1437
|
+
```typescript
|
|
1438
|
+
function batch<T>(fn: () => T): T;
|
|
1439
|
+
```
|
|
1440
|
+
|
|
1441
|
+
#### `emitter<T>()`
|
|
1442
|
+
|
|
1443
|
+
Creates a pub/sub event emitter.
|
|
1444
|
+
|
|
1445
|
+
```typescript
|
|
1446
|
+
function emitter<T>(): {
|
|
1447
|
+
on: (listener: (value: T) => void) => () => void;
|
|
1448
|
+
emit: (value: T) => void;
|
|
1449
|
+
settle: (value: T) => void;
|
|
1450
|
+
};
|
|
1451
|
+
```
|
|
1452
|
+
|
|
1453
|
+
#### `define<T>(factory, options?)`
|
|
1454
|
+
|
|
1455
|
+
Creates a swappable lazy singleton.
|
|
1456
|
+
|
|
1457
|
+
```typescript
|
|
1458
|
+
function define<T>(
|
|
1459
|
+
factory: () => T,
|
|
1460
|
+
options?: { eager?: boolean }
|
|
1461
|
+
): {
|
|
1462
|
+
(): T;
|
|
1463
|
+
override: (factory: () => T) => void;
|
|
1464
|
+
reset: () => void;
|
|
1465
|
+
};
|
|
1466
|
+
```
|
|
1467
|
+
|
|
1468
|
+
#### `isAtom(value)`
|
|
1469
|
+
|
|
1470
|
+
Type guard for atoms.
|
|
1471
|
+
|
|
1472
|
+
```typescript
|
|
1473
|
+
function isAtom<T>(value: unknown): value is Atom<T>;
|
|
1474
|
+
```
|
|
1475
|
+
|
|
1476
|
+
### SelectContext API
|
|
1477
|
+
|
|
1478
|
+
Available in `derived()`, `effect()`, `useValue()`, and `rx()`:
|
|
1479
|
+
|
|
1480
|
+
| Method | Signature | Description |
|
|
1481
|
+
| --------- | ---------------------------------- | -------------------------------------------- |
|
|
1482
|
+
| `get` | `<T>(atom: Atom<T>) => Awaited<T>` | Read atom value with dependency tracking |
|
|
1483
|
+
| `all` | `(...atoms) => [values...]` | Wait for all atoms (Promise.all semantics) |
|
|
1484
|
+
| `any` | `(...atoms) => value` | First ready value (Promise.any semantics) |
|
|
1485
|
+
| `race` | `(...atoms) => value` | First settled value (Promise.race semantics) |
|
|
1486
|
+
| `settled` | `(...atoms) => SettledResult[]` | All results (Promise.allSettled semantics) |
|
|
1487
|
+
|
|
1488
|
+
**Behavior:**
|
|
1489
|
+
|
|
1490
|
+
- `get()`: Returns value if ready, throws error if error, throws Promise if loading
|
|
1491
|
+
- `all()`: Suspends until all atoms are ready, throws on first error
|
|
1492
|
+
- `any()`: Returns first ready value, throws AggregateError if all error
|
|
1493
|
+
- `race()`: Returns first settled (ready or error)
|
|
1494
|
+
- `settled()`: Returns `{ status: "ready", value }` or `{ status: "error", error }` for each atom
|
|
1495
|
+
|
|
1496
|
+
### React API
|
|
1497
|
+
|
|
1498
|
+
#### `useValue`
|
|
1499
|
+
|
|
1500
|
+
```typescript
|
|
1501
|
+
// Shorthand
|
|
1502
|
+
function useValue<T>(atom: Atom<T>): T;
|
|
1503
|
+
|
|
1504
|
+
// Context selector
|
|
1505
|
+
function useValue<T>(
|
|
1506
|
+
selector: (context: SelectContext) => T,
|
|
1507
|
+
equals?: (prev: T, next: T) => boolean
|
|
1508
|
+
): T;
|
|
1509
|
+
```
|
|
1510
|
+
|
|
1511
|
+
#### `rx`
|
|
1512
|
+
|
|
1513
|
+
```typescript
|
|
1514
|
+
// Shorthand
|
|
1515
|
+
function rx<T>(atom: Atom<T>): ReactNode;
|
|
1516
|
+
|
|
1517
|
+
// Context selector
|
|
1518
|
+
function rx<T>(
|
|
1519
|
+
selector: (context: SelectContext) => T,
|
|
1520
|
+
equals?: (prev: T, next: T) => boolean
|
|
1521
|
+
): ReactNode;
|
|
1522
|
+
```
|
|
1523
|
+
|
|
1524
|
+
#### `useAction`
|
|
1525
|
+
|
|
1526
|
+
```typescript
|
|
1527
|
+
function useAction<T, Args extends any[]>(
|
|
1528
|
+
action: (context: { signal: AbortSignal }, ...args: Args) => Promise<T>,
|
|
1529
|
+
options?: {
|
|
1530
|
+
lazy?: boolean;
|
|
1531
|
+
deps?: (Atom<any> | any)[];
|
|
1532
|
+
}
|
|
1533
|
+
): {
|
|
1534
|
+
(...args: Args): void;
|
|
1535
|
+
status: "idle" | "loading" | "success" | "error";
|
|
1536
|
+
result: T | undefined;
|
|
1537
|
+
error: unknown;
|
|
1538
|
+
abort: () => void;
|
|
1539
|
+
reset: () => void;
|
|
1540
|
+
};
|
|
1541
|
+
```
|
|
1542
|
+
|
|
1543
|
+
#### `useStable`
|
|
1544
|
+
|
|
1545
|
+
```typescript
|
|
1546
|
+
function useStable<T extends Record<string, any>>(
|
|
1547
|
+
input: T,
|
|
1548
|
+
equals?: (prev: T[keyof T], next: T[keyof T]) => boolean
|
|
1549
|
+
): T;
|
|
1550
|
+
```
|
|
1551
|
+
|
|
1552
|
+
## TypeScript Integration
|
|
1553
|
+
|
|
1554
|
+
atomirx is written in TypeScript and provides full type inference:
|
|
1555
|
+
|
|
1556
|
+
```typescript
|
|
1557
|
+
import { atom, derived } from "atomirx";
|
|
1558
|
+
|
|
1559
|
+
// Types are automatically inferred
|
|
1560
|
+
const count$ = atom(0); // MutableAtom<number>
|
|
1561
|
+
const name$ = atom("John"); // MutableAtom<string>
|
|
1562
|
+
const doubled$ = derived(({ get }) => get(count$) * 2); // Atom<number>
|
|
1563
|
+
|
|
1564
|
+
// Explicit typing when needed
|
|
1565
|
+
interface User {
|
|
1566
|
+
id: string;
|
|
1567
|
+
name: string;
|
|
1568
|
+
email: string;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const user$ = atom<User | null>(null); // MutableAtom<User | null>
|
|
1572
|
+
const userData$ = atom<User>(fetchUser()); // MutableAtom<User>
|
|
1573
|
+
|
|
1574
|
+
// Type-safe selectors
|
|
1575
|
+
const userName$ = derived(({ get }) => {
|
|
1576
|
+
const user = get(user$);
|
|
1577
|
+
return user?.name ?? "Anonymous"; // Atom<string>
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
// Generic atoms
|
|
1581
|
+
function createListAtom<T>(initial: T[] = []) {
|
|
1582
|
+
const items$ = atom<T[]>(initial);
|
|
1583
|
+
|
|
1584
|
+
return {
|
|
1585
|
+
items$,
|
|
1586
|
+
add: (item: T) => items$.set((list) => [...list, item]),
|
|
1587
|
+
remove: (predicate: (item: T) => boolean) =>
|
|
1588
|
+
items$.set((list) => list.filter((item) => !predicate(item))),
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
const todoList = createListAtom<Todo>();
|
|
1593
|
+
```
|
|
1594
|
+
|
|
1595
|
+
## Comparison with Other Libraries
|
|
1596
|
+
|
|
1597
|
+
| Feature | atomirx | Redux Toolkit | Zustand | Jotai | Recoil |
|
|
1598
|
+
| -------------------- | ----------- | ------------- | ------- | ----------- | -------- |
|
|
1599
|
+
| Bundle size | ~3KB | ~12KB | ~3KB | ~8KB | ~20KB |
|
|
1600
|
+
| Boilerplate | Minimal | Low | Minimal | Minimal | Medium |
|
|
1601
|
+
| TypeScript | First-class | First-class | Good | First-class | Good |
|
|
1602
|
+
| Async support | Built-in | RTK Query | Manual | Built-in | Built-in |
|
|
1603
|
+
| Fine-grained updates | Yes | No | Partial | Yes | Yes |
|
|
1604
|
+
| Suspense support | Native | No | No | Yes | Yes |
|
|
1605
|
+
| DevTools | Planned | Yes | Yes | Yes | Yes |
|
|
1606
|
+
| Learning curve | Low | Medium | Low | Low | Medium |
|
|
1607
|
+
|
|
1608
|
+
### When to Use atomirx
|
|
1609
|
+
|
|
1610
|
+
**Choose atomirx when you want:**
|
|
1611
|
+
|
|
1612
|
+
- Minimal API with maximum capability
|
|
1613
|
+
- First-class async support without additional packages
|
|
1614
|
+
- Fine-grained reactivity for optimal performance
|
|
1615
|
+
- React Suspense integration out of the box
|
|
1616
|
+
- Strong TypeScript inference
|
|
1617
|
+
|
|
1618
|
+
**Consider alternatives when you need:**
|
|
1619
|
+
|
|
1620
|
+
- Time-travel debugging (Redux Toolkit)
|
|
1621
|
+
- Established ecosystem with many plugins (Redux)
|
|
1622
|
+
- Server-side state management (TanStack Query)
|
|
1623
|
+
|
|
1624
|
+
## Resources & Learning
|
|
1625
|
+
|
|
1626
|
+
### Documentation
|
|
1627
|
+
|
|
1628
|
+
- [API Reference](#api-reference) - Complete API documentation
|
|
1629
|
+
- [Usage Guide](#usage-guide) - In-depth usage patterns
|
|
1630
|
+
- [React Integration](#react-integration) - React-specific features
|
|
1631
|
+
|
|
1632
|
+
### Examples
|
|
1633
|
+
|
|
1634
|
+
- [Counter](./examples/counter) - Basic counter example
|
|
1635
|
+
- [Todo App](./examples/todo) - Full todo application
|
|
1636
|
+
- [Async Data](./examples/async) - Async data fetching patterns
|
|
1637
|
+
- [Testing](./examples/testing) - Testing strategies with `define()`
|
|
1638
|
+
|
|
1639
|
+
### Community
|
|
1640
|
+
|
|
1641
|
+
- [GitHub Issues](https://github.com/atomirx/atomirx/issues) - Bug reports and feature requests
|
|
1642
|
+
- [Discussions](https://github.com/atomirx/atomirx/discussions) - Questions and community support
|
|
1643
|
+
|
|
1644
|
+
## License
|
|
1645
|
+
|
|
1646
|
+
MIT License
|
|
1647
|
+
|
|
1648
|
+
Copyright (c) atomirx contributors
|
|
1649
|
+
|
|
1650
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
1651
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
1652
|
+
in the Software without restriction, including without limitation the rights
|
|
1653
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
1654
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
1655
|
+
furnished to do so, subject to the following conditions:
|
|
1656
|
+
|
|
1657
|
+
The above copyright notice and this permission notice shall be included in all
|
|
1658
|
+
copies or substantial portions of the Software.
|
|
1659
|
+
|
|
1660
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
1661
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
1662
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
1663
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
1664
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
1665
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
1666
|
+
SOFTWARE.
|