atomirx 0.0.7 → 0.1.0
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 +198 -2234
- package/bin/cli.js +90 -0
- package/dist/core/derived.d.ts +2 -2
- package/dist/core/effect.d.ts +3 -2
- package/dist/core/onCreateHook.d.ts +15 -2
- package/dist/core/onErrorHook.d.ts +4 -1
- package/dist/core/pool.d.ts +78 -0
- package/dist/core/pool.test.d.ts +1 -0
- package/dist/core/select-boolean.test.d.ts +1 -0
- package/dist/core/select-pool.test.d.ts +1 -0
- package/dist/core/select.d.ts +278 -86
- package/dist/core/types.d.ts +233 -1
- package/dist/core/withAbort.d.ts +95 -0
- package/dist/core/withReady.d.ts +3 -3
- package/dist/devtools/constants.d.ts +41 -0
- package/dist/devtools/index.cjs +1 -0
- package/dist/devtools/index.d.ts +29 -0
- package/dist/devtools/index.js +429 -0
- package/dist/devtools/registry.d.ts +98 -0
- package/dist/devtools/registry.test.d.ts +1 -0
- package/dist/devtools/setup.d.ts +61 -0
- package/dist/devtools/types.d.ts +311 -0
- package/dist/index-BZEnfIcB.cjs +1 -0
- package/dist/index-BbPZhsDl.js +1653 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +18 -14
- package/dist/onDispatchHook-C8yLzr-o.cjs +1 -0
- package/dist/onDispatchHook-SKbiIUaJ.js +5 -0
- package/dist/onErrorHook-BGGy3tqK.js +38 -0
- package/dist/onErrorHook-DHBASmYw.cjs +1 -0
- package/dist/react/index.cjs +1 -30
- package/dist/react/index.js +206 -791
- package/dist/react/onDispatchHook.d.ts +106 -0
- package/dist/react/useAction.d.ts +4 -1
- package/dist/react-devtools/DevToolsPanel.d.ts +93 -0
- package/dist/react-devtools/EntityDetails.d.ts +10 -0
- package/dist/react-devtools/EntityList.d.ts +15 -0
- package/dist/react-devtools/LogList.d.ts +12 -0
- package/dist/react-devtools/hooks.d.ts +50 -0
- package/dist/react-devtools/index.cjs +1 -0
- package/dist/react-devtools/index.d.ts +31 -0
- package/dist/react-devtools/index.js +1589 -0
- package/dist/react-devtools/styles.d.ts +148 -0
- package/package.json +26 -2
- package/skills/atomirx/SKILL.md +456 -0
- package/skills/atomirx/references/async-patterns.md +188 -0
- package/skills/atomirx/references/atom-patterns.md +238 -0
- package/skills/atomirx/references/deferred-loading.md +191 -0
- package/skills/atomirx/references/derived-patterns.md +428 -0
- package/skills/atomirx/references/effect-patterns.md +426 -0
- package/skills/atomirx/references/error-handling.md +140 -0
- package/skills/atomirx/references/hooks.md +322 -0
- package/skills/atomirx/references/pool-patterns.md +229 -0
- package/skills/atomirx/references/react-integration.md +411 -0
- package/skills/atomirx/references/rules.md +407 -0
- package/skills/atomirx/references/select-context.md +309 -0
- package/skills/atomirx/references/service-template.md +172 -0
- package/skills/atomirx/references/store-template.md +205 -0
- package/skills/atomirx/references/testing-patterns.md +431 -0
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/clover.xml +0 -1440
- package/coverage/coverage-final.json +0 -14
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -131
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/src/core/atom.ts.html +0 -889
- package/coverage/src/core/batch.ts.html +0 -223
- package/coverage/src/core/define.ts.html +0 -805
- package/coverage/src/core/emitter.ts.html +0 -919
- package/coverage/src/core/equality.ts.html +0 -631
- package/coverage/src/core/hook.ts.html +0 -460
- package/coverage/src/core/index.html +0 -281
- package/coverage/src/core/isAtom.ts.html +0 -100
- package/coverage/src/core/isPromiseLike.ts.html +0 -133
- package/coverage/src/core/onCreateHook.ts.html +0 -138
- package/coverage/src/core/scheduleNotifyHook.ts.html +0 -94
- package/coverage/src/core/types.ts.html +0 -523
- package/coverage/src/core/withUse.ts.html +0 -253
- package/coverage/src/index.html +0 -116
- package/coverage/src/index.ts.html +0 -106
- package/dist/index-CBVj1kSj.js +0 -1350
- package/dist/index-Cxk9v0um.cjs +0 -1
- package/scripts/publish.js +0 -198
- package/src/core/atom.test.ts +0 -633
- package/src/core/atom.ts +0 -311
- package/src/core/atomState.test.ts +0 -342
- package/src/core/atomState.ts +0 -256
- package/src/core/batch.test.ts +0 -257
- package/src/core/batch.ts +0 -172
- package/src/core/define.test.ts +0 -343
- package/src/core/define.ts +0 -243
- package/src/core/derived.test.ts +0 -1215
- package/src/core/derived.ts +0 -450
- package/src/core/effect.test.ts +0 -802
- package/src/core/effect.ts +0 -188
- package/src/core/emitter.test.ts +0 -364
- package/src/core/emitter.ts +0 -392
- package/src/core/equality.test.ts +0 -392
- package/src/core/equality.ts +0 -182
- package/src/core/getAtomState.ts +0 -69
- package/src/core/hook.test.ts +0 -227
- package/src/core/hook.ts +0 -177
- package/src/core/isAtom.ts +0 -27
- package/src/core/isPromiseLike.test.ts +0 -72
- package/src/core/isPromiseLike.ts +0 -16
- package/src/core/onCreateHook.ts +0 -107
- package/src/core/onErrorHook.test.ts +0 -350
- package/src/core/onErrorHook.ts +0 -52
- package/src/core/promiseCache.test.ts +0 -241
- package/src/core/promiseCache.ts +0 -284
- package/src/core/scheduleNotifyHook.ts +0 -53
- package/src/core/select.ts +0 -729
- package/src/core/selector.test.ts +0 -799
- package/src/core/types.ts +0 -389
- package/src/core/withReady.test.ts +0 -534
- package/src/core/withReady.ts +0 -191
- package/src/core/withUse.test.ts +0 -249
- package/src/core/withUse.ts +0 -56
- package/src/index.test.ts +0 -80
- package/src/index.ts +0 -65
- package/src/react/index.ts +0 -21
- package/src/react/rx.test.tsx +0 -571
- package/src/react/rx.tsx +0 -531
- package/src/react/strictModeTest.tsx +0 -71
- package/src/react/useAction.test.ts +0 -987
- package/src/react/useAction.ts +0 -607
- package/src/react/useSelector.test.ts +0 -182
- package/src/react/useSelector.ts +0 -292
- package/src/react/useStable.test.ts +0 -553
- package/src/react/useStable.ts +0 -288
- package/tsconfig.json +0 -9
- package/v2.md +0 -725
- package/vite.config.ts +0 -39
package/README.md
CHANGED
|
@@ -1,2373 +1,337 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
**Opinionated, Batteries-Included Reactive State Management**
|
|
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
|
-
- [Why atomirx?](#why-atomirx)
|
|
28
|
-
- [The Problem](#the-problem)
|
|
29
|
-
- [The Solution](#the-solution)
|
|
30
|
-
- [Design Philosophy](#design-philosophy)
|
|
31
|
-
- [What's Included](#whats-included)
|
|
32
|
-
- [Core](#core)
|
|
33
|
-
- [React Bindings (`atomirx/react`)](#react-bindings-atomirxreact)
|
|
34
|
-
- [Getting Started](#getting-started)
|
|
35
|
-
- [Basic Example: Counter](#basic-example-counter)
|
|
36
|
-
- [React Example: Todo App](#react-example-todo-app)
|
|
37
|
-
- [Patterns \& Best Practices](#patterns--best-practices)
|
|
38
|
-
- [Naming: The `$` Suffix](#naming-the--suffix)
|
|
39
|
-
- [When to Use Each Primitive](#when-to-use-each-primitive)
|
|
40
|
-
- [Atom Storage: Stable Scopes Only](#atom-storage-stable-scopes-only)
|
|
41
|
-
- [Error Handling: Use `safe()` Not try/catch](#error-handling-use-safe-not-trycatch)
|
|
42
|
-
- [The Problem with try/catch](#the-problem-with-trycatch)
|
|
43
|
-
- [The Solution: safe()](#the-solution-safe)
|
|
44
|
-
- [Use Cases for safe()](#use-cases-for-safe)
|
|
45
|
-
- [SelectContext Methods: Synchronous Only](#selectcontext-methods-synchronous-only)
|
|
46
|
-
- [Complete Example: Todo App with Async](#complete-example-todo-app-with-async)
|
|
47
|
-
- [Deferred Entity Loading with `ready()`](#deferred-entity-loading-with-ready)
|
|
48
|
-
- [The Pattern](#the-pattern)
|
|
49
|
-
- [How It Works](#how-it-works)
|
|
50
|
-
- [Component Usage](#component-usage)
|
|
51
|
-
- [Benefits](#benefits)
|
|
52
|
-
- [When to Use `ready()`](#when-to-use-ready)
|
|
53
|
-
- [Important Notes](#important-notes)
|
|
54
|
-
- [Usage Guide](#usage-guide)
|
|
55
|
-
- [Atoms: The Foundation](#atoms-the-foundation)
|
|
56
|
-
- [Creating Atoms](#creating-atoms)
|
|
57
|
-
- [Reading Atom Values](#reading-atom-values)
|
|
58
|
-
- [Updating Atoms](#updating-atoms)
|
|
59
|
-
- [Subscribing to Changes](#subscribing-to-changes)
|
|
60
|
-
- [Complete Atom API](#complete-atom-api)
|
|
61
|
-
- [Derived State: Computed Values](#derived-state-computed-values)
|
|
62
|
-
- [Basic Derived State](#basic-derived-state)
|
|
63
|
-
- [Conditional Dependencies](#conditional-dependencies)
|
|
64
|
-
- [Suspense-Style Getters](#suspense-style-getters)
|
|
65
|
-
- [Derived from Multiple Async Sources](#derived-from-multiple-async-sources)
|
|
66
|
-
- [Effects: Side Effect Management](#effects-side-effect-management)
|
|
67
|
-
- [Basic Effects](#basic-effects)
|
|
68
|
-
- [Effects with Cleanup](#effects-with-cleanup)
|
|
69
|
-
- [Effects with Multiple Dependencies](#effects-with-multiple-dependencies)
|
|
70
|
-
- [Async Patterns](#async-patterns)
|
|
71
|
-
- [`all()` - Wait for All (like Promise.all)](#all---wait-for-all-like-promiseall)
|
|
72
|
-
- [`any()` - First Ready (like Promise.any)](#any---first-ready-like-promiseany)
|
|
73
|
-
- [`race()` - First Settled (like Promise.race)](#race---first-settled-like-promiserace)
|
|
74
|
-
- [`settled()` - All Results (like Promise.allSettled)](#settled---all-results-like-promiseallsettled)
|
|
75
|
-
- [Async Utility Summary](#async-utility-summary)
|
|
76
|
-
- [Batching Updates](#batching-updates)
|
|
77
|
-
- [Event System](#event-system)
|
|
78
|
-
- [Dependency Injection](#dependency-injection)
|
|
79
|
-
- [Atom Metadata and Middleware](#atom-metadata-and-middleware)
|
|
80
|
-
- [Extending AtomMeta with TypeScript](#extending-atommeta-with-typescript)
|
|
81
|
-
- [Using onCreateHook for Middleware](#using-oncreatehook-for-middleware)
|
|
82
|
-
- [Creating Persisted Atoms](#creating-persisted-atoms)
|
|
83
|
-
- [Multiple Middleware Example](#multiple-middleware-example)
|
|
84
|
-
- [Hook Info Types](#hook-info-types)
|
|
85
|
-
- [React Integration](#react-integration)
|
|
86
|
-
- [useSelector Hook](#useselector-hook)
|
|
87
|
-
- [Custom Equality](#custom-equality)
|
|
88
|
-
- [Why useSelector is Powerful](#why-useselector-is-powerful)
|
|
89
|
-
- [Reactive Components with rx](#reactive-components-with-rx)
|
|
90
|
-
- [Inline Loading and Error Handling](#inline-loading-and-error-handling)
|
|
91
|
-
- [Selector Memoization with `deps`](#selector-memoization-with-deps)
|
|
92
|
-
- [Async Actions with useAction](#async-actions-with-useaction)
|
|
93
|
-
- [Eager Execution](#eager-execution)
|
|
94
|
-
- [useAction API](#useaction-api)
|
|
95
|
-
- [Reference Stability with useStable](#reference-stability-with-usestable)
|
|
96
|
-
- [Suspense Integration](#suspense-integration)
|
|
97
|
-
- [Nested Suspense Boundaries](#nested-suspense-boundaries)
|
|
98
|
-
- [API Reference](#api-reference)
|
|
99
|
-
- [Core API](#core-api)
|
|
100
|
-
- [`atom<T>(initialValue, options?)`](#atomtinitialvalue-options)
|
|
101
|
-
- [`derived<T>(selector)`](#derivedtselector)
|
|
102
|
-
- [`effect(fn, options?)`](#effectfn-options)
|
|
103
|
-
- [`batch(fn)`](#batchfn)
|
|
104
|
-
- [`emitter<T>()`](#emittert)
|
|
105
|
-
- [`define<T>(factory, options?)`](#definetfactory-options)
|
|
106
|
-
- [`isAtom(value)`](#isatomvalue)
|
|
107
|
-
- [SelectContext API](#selectcontext-api)
|
|
108
|
-
- [`state()` - Get Async State Without Throwing](#state---get-async-state-without-throwing)
|
|
109
|
-
- [React API](#react-api)
|
|
110
|
-
- [`useSelector`](#useselector)
|
|
111
|
-
- [`rx`](#rx)
|
|
112
|
-
- [`useAction`](#useaction)
|
|
113
|
-
- [`useStable`](#usestable)
|
|
114
|
-
- [TypeScript Integration](#typescript-integration)
|
|
115
|
-
- [Comparison with Other Libraries](#comparison-with-other-libraries)
|
|
116
|
-
- [When to Use atomirx](#when-to-use-atomirx)
|
|
117
|
-
- [Resources \& Learning](#resources--learning)
|
|
118
|
-
- [Documentation](#documentation)
|
|
119
|
-
- [Examples](#examples)
|
|
120
|
-
- [Community](#community)
|
|
121
|
-
- [License](#license)
|
|
122
|
-
|
|
123
|
-
## Installation
|
|
124
|
-
|
|
125
|
-
atomirx is available as a package on NPM for use with a module bundler or in a Node application:
|
|
1
|
+
<div align="center">
|
|
126
2
|
|
|
127
|
-
|
|
128
|
-
# NPM
|
|
129
|
-
npm install atomirx
|
|
130
|
-
|
|
131
|
-
# Yarn
|
|
132
|
-
yarn add atomirx
|
|
133
|
-
|
|
134
|
-
# PNPM
|
|
135
|
-
pnpm add atomirx
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
The package includes precompiled ESM and CommonJS builds, along with TypeScript type definitions.
|
|
139
|
-
|
|
140
|
-
## Why atomirx?
|
|
141
|
-
|
|
142
|
-
### The Problem
|
|
143
|
-
|
|
144
|
-
Traditional state management solutions often require:
|
|
145
|
-
|
|
146
|
-
- **Excessive boilerplate** for simple state updates
|
|
147
|
-
- **Separate packages** for async operations, caching, and derived state
|
|
148
|
-
- **Manual subscription management** leading to memory leaks
|
|
149
|
-
- **Coarse-grained updates** causing unnecessary re-renders
|
|
150
|
-
- **Complex mental models** for understanding data flow
|
|
151
|
-
|
|
152
|
-
### The Solution
|
|
153
|
-
|
|
154
|
-
atomirx provides a **unified, minimal API** that handles all common state management patterns out of the box:
|
|
155
|
-
|
|
156
|
-
| Challenge | atomirx Solution |
|
|
157
|
-
| ----------------- | -------------------------------------------------------------- |
|
|
158
|
-
| Mutable state | `atom()` - single source of truth with automatic subscriptions |
|
|
159
|
-
| Computed values | `derived()` - automatic dependency tracking and memoization |
|
|
160
|
-
| Side effects | `effect()` - declarative effects with cleanup |
|
|
161
|
-
| Async operations | Built-in Promise support with loading/error states |
|
|
162
|
-
| React integration | Suspense-compatible hooks with fine-grained updates |
|
|
163
|
-
| Testing | `define()` - dependency injection with easy mocking |
|
|
164
|
-
|
|
165
|
-
### Design Philosophy
|
|
166
|
-
|
|
167
|
-
atomirx is built on these core principles:
|
|
168
|
-
|
|
169
|
-
1. **Minimal API Surface** - Learn three functions (`atom`, `derived`, `effect`) and you're productive
|
|
170
|
-
2. **Async-First** - Promises are first-class citizens, not an afterthought
|
|
171
|
-
3. **Fine-Grained Reactivity** - Only the code that needs to run, runs
|
|
172
|
-
4. **Type Safety** - Full TypeScript inference without manual type annotations
|
|
173
|
-
5. **Framework Agnostic** - Core library has zero dependencies; React bindings are optional
|
|
174
|
-
6. **Suspense-Native** - Designed from the ground up for React Suspense patterns
|
|
175
|
-
|
|
176
|
-
## What's Included
|
|
177
|
-
|
|
178
|
-
atomirx includes these APIs:
|
|
179
|
-
|
|
180
|
-
### Core
|
|
181
|
-
|
|
182
|
-
- **`atom()`**: Creates mutable reactive state containers with built-in async support
|
|
183
|
-
- **`derived()`**: Creates computed values with automatic dependency tracking
|
|
184
|
-
- **`effect()`**: Runs side effects that automatically re-execute when dependencies change
|
|
185
|
-
- **`batch()`**: Groups multiple updates into a single notification cycle
|
|
186
|
-
- **`define()`**: Creates swappable lazy singletons for dependency injection
|
|
187
|
-
|
|
188
|
-
### React Bindings (`atomirx/react`)
|
|
189
|
-
|
|
190
|
-
- **`useSelector()`**: Subscribe to atoms with automatic re-rendering (Suspense-based)
|
|
191
|
-
- **`rx()`**: Inline reactive components with optional loading/error handlers
|
|
192
|
-
- **`useAction()`**: Handle async operations with loading/error states
|
|
193
|
-
- **`useStable()`**: Stabilize object/array/callback references
|
|
194
|
-
|
|
195
|
-
## Getting Started
|
|
196
|
-
|
|
197
|
-
### Basic Example: Counter
|
|
198
|
-
|
|
199
|
-
```typescript
|
|
200
|
-
import { atom, derived, effect } from "atomirx";
|
|
201
|
-
|
|
202
|
-
// Step 1: Create an atom (mutable state)
|
|
203
|
-
const count$ = atom(0);
|
|
204
|
-
|
|
205
|
-
// Step 2: Create derived state (computed values)
|
|
206
|
-
const doubled$ = derived(({ read }) => read(count$) * 2);
|
|
207
|
-
const message$ = derived(({ read }) => {
|
|
208
|
-
const count = read(count$);
|
|
209
|
-
return count === 0 ? "Click to start!" : `Count: ${count}`;
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// Step 3: React to changes with effects
|
|
213
|
-
effect(({ read }) => {
|
|
214
|
-
console.log("Current count:", read(count$));
|
|
215
|
-
console.log("Doubled value:", read(doubled$));
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
// Step 4: Update state
|
|
219
|
-
count$.set(5); // Logs: Current count: 5, Doubled value: 10
|
|
220
|
-
count$.set((n) => n + 1); // Logs: Current count: 6, Doubled value: 12
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
### React Example: Todo App
|
|
224
|
-
|
|
225
|
-
```tsx
|
|
226
|
-
import { atom, derived } from "atomirx";
|
|
227
|
-
import { useSelector, rx } from "atomirx/react";
|
|
228
|
-
|
|
229
|
-
// Define your state
|
|
230
|
-
interface Todo {
|
|
231
|
-
id: number;
|
|
232
|
-
text: string;
|
|
233
|
-
completed: boolean;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const todos$ = atom<Todo[]>([]);
|
|
237
|
-
const filter$ = atom<"all" | "active" | "completed">("all");
|
|
238
|
-
|
|
239
|
-
// Derive computed state
|
|
240
|
-
const filteredTodos$ = derived(({ read }) => {
|
|
241
|
-
const todos = read(todos$);
|
|
242
|
-
const filter = read(filter$);
|
|
243
|
-
|
|
244
|
-
switch (filter) {
|
|
245
|
-
case "active":
|
|
246
|
-
return todos.filter((t) => !t.completed);
|
|
247
|
-
case "completed":
|
|
248
|
-
return todos.filter((t) => t.completed);
|
|
249
|
-
default:
|
|
250
|
-
return todos;
|
|
251
|
-
}
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
const stats$ = derived(({ read }) => {
|
|
255
|
-
const todos = read(todos$);
|
|
256
|
-
return {
|
|
257
|
-
total: todos.length,
|
|
258
|
-
completed: todos.filter((t) => t.completed).length,
|
|
259
|
-
remaining: todos.filter((t) => !t.completed).length,
|
|
260
|
-
};
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
// Actions
|
|
264
|
-
const addTodo = (text: string) => {
|
|
265
|
-
todos$.set((todos) => [...todos, { id: Date.now(), text, completed: false }]);
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
const toggleTodo = (id: number) => {
|
|
269
|
-
todos$.set((todos) =>
|
|
270
|
-
todos.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
|
|
271
|
-
);
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
// Components
|
|
275
|
-
function TodoList() {
|
|
276
|
-
const todos = useSelector(filteredTodos$);
|
|
277
|
-
|
|
278
|
-
return (
|
|
279
|
-
<ul>
|
|
280
|
-
{todos.map((todo) => (
|
|
281
|
-
<li key={todo.id} onClick={() => toggleTodo(todo.id)}>
|
|
282
|
-
{todo.completed ? "✓" : "○"} {todo.text}
|
|
283
|
-
</li>
|
|
284
|
-
))}
|
|
285
|
-
</ul>
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function Stats() {
|
|
290
|
-
// Fine-grained updates: only re-renders when stats change
|
|
291
|
-
return (
|
|
292
|
-
<footer>
|
|
293
|
-
{rx(({ read }) => {
|
|
294
|
-
const { total, completed, remaining } = read(stats$);
|
|
295
|
-
return (
|
|
296
|
-
<span>
|
|
297
|
-
{remaining} of {total} remaining
|
|
298
|
-
</span>
|
|
299
|
-
);
|
|
300
|
-
})}
|
|
301
|
-
</footer>
|
|
302
|
-
);
|
|
303
|
-
}
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
## Patterns & Best Practices
|
|
307
|
-
|
|
308
|
-
Following consistent patterns and best practices makes atomirx code more readable and maintainable across your team.
|
|
309
|
-
|
|
310
|
-
### Naming: The `$` Suffix
|
|
311
|
-
|
|
312
|
-
All atoms (both `atom()` and `derived()`) should use the `$` suffix. This convention:
|
|
313
|
-
|
|
314
|
-
- Clearly distinguishes reactive state from regular variables
|
|
315
|
-
- Makes it obvious when you're working with atoms vs plain values
|
|
316
|
-
- Improves code readability at a glance
|
|
317
|
-
|
|
318
|
-
```typescript
|
|
319
|
-
// ✅ Good - clear that these are atoms
|
|
320
|
-
const count$ = atom(0);
|
|
321
|
-
const user$ = atom<User | null>(null);
|
|
322
|
-
const filteredItems$ = derived(({ read }) => /* ... */);
|
|
323
|
-
|
|
324
|
-
// ❌ Avoid - unclear what's reactive
|
|
325
|
-
const count = atom(0);
|
|
326
|
-
const user = atom<User | null>(null);
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
### When to Use Each Primitive
|
|
330
|
-
|
|
331
|
-
| Primitive | Purpose | Use When |
|
|
332
|
-
| ----------- | ----------------------- | --------------------------------------------------------------------------- |
|
|
333
|
-
| `atom()` | Store values | You need mutable state (including Promises) |
|
|
334
|
-
| `derived()` | Compute reactive values | You need to transform or combine atom values |
|
|
335
|
-
| `effect()` | Trigger side effects | You need to react to atom changes (sync to external systems, logging, etc.) |
|
|
336
|
-
|
|
337
|
-
### Atom Storage: Stable Scopes Only
|
|
338
|
-
|
|
339
|
-
**Never store atoms in component/local scope.** Atoms created inside React components (even with `useRef`) can lead to:
|
|
340
|
-
|
|
341
|
-
- **Memory leaks** - atoms aren't properly disposed when components unmount
|
|
342
|
-
- **Forgotten disposal** - easy to forget cleanup logic
|
|
343
|
-
- **Multiple instances** - each component render may create new atoms
|
|
344
|
-
|
|
345
|
-
```typescript
|
|
346
|
-
// ❌ BAD - atoms in component scope
|
|
347
|
-
function TodoList() {
|
|
348
|
-
// These atoms are created per component instance!
|
|
349
|
-
const todos$ = useRef(atom(() => fetchTodos())).current;
|
|
350
|
-
const filter$ = useRef(atom("all")).current;
|
|
351
|
-
// Memory leak: atoms not disposed on unmount
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// ✅ GOOD - atoms at module scope
|
|
355
|
-
const todos$ = atom(() => fetchTodos());
|
|
356
|
-
const filter$ = atom("all");
|
|
357
|
-
|
|
358
|
-
function TodoList() {
|
|
359
|
-
const todos = useSelector(filteredTodos$);
|
|
360
|
-
// ...
|
|
361
|
-
}
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
**Use `define()` to organize atoms into modules:**
|
|
365
|
-
|
|
366
|
-
```typescript
|
|
367
|
-
// ✅ BEST - atoms organized in a module with define()
|
|
368
|
-
const TodoModule = define(() => {
|
|
369
|
-
// Atoms are created once, lazily
|
|
370
|
-
const todos$ = atom(() => fetchTodos(), { meta: { key: "todos" } });
|
|
371
|
-
const filter$ = atom<"all" | "active" | "completed">("all");
|
|
372
|
-
|
|
373
|
-
const filteredTodos$ = derived(({ read }) => {
|
|
374
|
-
const filter = read(filter$);
|
|
375
|
-
const todos = read(todos$);
|
|
376
|
-
return filter === "all" ? todos : todos.filter(/* ... */);
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
return {
|
|
380
|
-
todos$,
|
|
381
|
-
filter$,
|
|
382
|
-
filteredTodos$,
|
|
383
|
-
setFilter: (f: "all" | "active" | "completed") => filter$.set(f),
|
|
384
|
-
refetch: () => todos$.set(fetchTodos()),
|
|
385
|
-
reset: () => todos$.reset(),
|
|
386
|
-
};
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
// Usage in React
|
|
390
|
-
function TodoList() {
|
|
391
|
-
const { filteredTodos$, setFilter } = TodoModule();
|
|
392
|
-
const todos = useSelector(filteredTodos$);
|
|
393
|
-
// ...
|
|
394
|
-
}
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
Benefits of `define()`:
|
|
398
|
-
|
|
399
|
-
- **Lazy singleton** - module created on first access
|
|
400
|
-
- **Testable** - use `.override()` to inject mocks
|
|
401
|
-
- **Disposable** - use `.invalidate()` to clean up and recreate
|
|
402
|
-
- **Organized** - group related atoms and actions together
|
|
403
|
-
|
|
404
|
-
**`atom()`** - Store raw values, including Promises:
|
|
405
|
-
|
|
406
|
-
```typescript
|
|
407
|
-
// Synchronous values
|
|
408
|
-
const filter$ = atom("all");
|
|
409
|
-
const count$ = atom(0);
|
|
410
|
-
|
|
411
|
-
// Async values - store the Promise directly
|
|
412
|
-
const todoList$ = atom(() => fetchAllTodos()); // Lazy fetch on creation
|
|
413
|
-
const userData$ = atom(fetchUser(userId)); // Fetch immediately
|
|
414
|
-
```
|
|
415
|
-
|
|
416
|
-
**`derived()`** - Handle reactive/async transformations:
|
|
417
|
-
|
|
418
|
-
```typescript
|
|
419
|
-
// derived() automatically unwraps Promises from atoms
|
|
420
|
-
const filteredTodoList$ = derived(({ read }) => {
|
|
421
|
-
const filter = read(filter$);
|
|
422
|
-
const todoList = read(todoList$); // Promise is unwrapped automatically!
|
|
423
|
-
|
|
424
|
-
switch (filter) {
|
|
425
|
-
case "active":
|
|
426
|
-
return todoList.filter((t) => !t.completed);
|
|
427
|
-
case "completed":
|
|
428
|
-
return todoList.filter((t) => t.completed);
|
|
429
|
-
default:
|
|
430
|
-
return todoList;
|
|
431
|
-
}
|
|
432
|
-
});
|
|
433
|
-
```
|
|
434
|
-
|
|
435
|
-
**`effect()`** - Coordinate updates across multiple atoms:
|
|
436
|
-
|
|
437
|
-
```typescript
|
|
438
|
-
// Sync local state to server when it changes
|
|
439
|
-
effect(({ read, onCleanup }) => {
|
|
440
|
-
const settings = read(settings$);
|
|
441
|
-
|
|
442
|
-
const controller = new AbortController();
|
|
443
|
-
saveSettingsToServer(settings, { signal: controller.signal });
|
|
444
|
-
|
|
445
|
-
onCleanup(() => controller.abort());
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
// Update multiple atoms based on another atom's change
|
|
449
|
-
effect(({ read }) => {
|
|
450
|
-
const user = read(currentUser$);
|
|
451
|
-
|
|
452
|
-
if (user) {
|
|
453
|
-
// Trigger fetches for user-specific data
|
|
454
|
-
userPosts$.set(fetchUserPosts(user.id));
|
|
455
|
-
userSettings$.set(fetchUserSettings(user.id));
|
|
456
|
-
}
|
|
457
|
-
});
|
|
458
|
-
```
|
|
459
|
-
|
|
460
|
-
### Error Handling: Use `safe()` Not try/catch
|
|
461
|
-
|
|
462
|
-
When working with reactive selectors in `derived()`, `effect()`, `useSelector()`, and `rx()`, you need to be careful about how you handle errors. The standard JavaScript `try/catch` pattern can break atomirx's Suspense mechanism.
|
|
463
|
-
|
|
464
|
-
#### The Problem with try/catch
|
|
465
|
-
|
|
466
|
-
The `read()` function in selectors uses a **Suspense-like pattern**: when an atom is loading (contains a pending Promise), `read()` throws that Promise. This is how atomirx signals to React's Suspense that it should show a fallback.
|
|
467
|
-
|
|
468
|
-
**If you wrap `read()` in a try/catch, you'll catch the Promise** along with any actual errors:
|
|
469
|
-
|
|
470
|
-
```typescript
|
|
471
|
-
// ❌ WRONG - This breaks Suspense!
|
|
472
|
-
const data$ = derived(({ read }) => {
|
|
473
|
-
try {
|
|
474
|
-
const user = read(asyncUser$); // Throws Promise when loading!
|
|
475
|
-
return processUser(user);
|
|
476
|
-
} catch (e) {
|
|
477
|
-
// This catches BOTH:
|
|
478
|
-
// 1. The Promise (when loading) - breaks Suspense!
|
|
479
|
-
// 2. Actual errors from processUser()
|
|
480
|
-
return null;
|
|
481
|
-
}
|
|
482
|
-
});
|
|
483
|
-
```
|
|
484
|
-
|
|
485
|
-
This causes several problems:
|
|
486
|
-
|
|
487
|
-
1. **Loading state is lost** - Instead of suspending, your derived atom immediately returns `null`
|
|
488
|
-
2. **No Suspense fallback** - React never sees the loading state
|
|
489
|
-
3. **Silent failures** - You can't distinguish between "loading" and "error"
|
|
490
|
-
|
|
491
|
-
#### The Solution: safe()
|
|
492
|
-
|
|
493
|
-
atomirx provides the `safe()` utility in all selector contexts. It catches actual errors but **re-throws Promises** to preserve Suspense:
|
|
494
|
-
|
|
495
|
-
```typescript
|
|
496
|
-
// ✅ CORRECT - Use safe() for error handling
|
|
497
|
-
const data$ = derived(({ read, safe }) => {
|
|
498
|
-
const [err, user] = safe(() => {
|
|
499
|
-
const raw = read(asyncUser$); // Can throw Promise (Suspense) ✓
|
|
500
|
-
return processUser(raw); // Can throw Error ✓
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
if (err) {
|
|
504
|
-
// Only actual errors reach here, not loading state
|
|
505
|
-
console.error("Processing failed:", err);
|
|
506
|
-
return { error: err.message };
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
return { user };
|
|
510
|
-
});
|
|
511
|
-
```
|
|
512
|
-
|
|
513
|
-
**How `safe()` works:**
|
|
514
|
-
|
|
515
|
-
| Scenario | `try/catch` | `safe()` |
|
|
516
|
-
| ----------------- | ------------------ | ------------------------------- |
|
|
517
|
-
| Loading (Promise) | ❌ Catches Promise | ✅ Re-throws → Suspense |
|
|
518
|
-
| Error | ✅ Catches error | ✅ Returns `[error, undefined]` |
|
|
519
|
-
| Success | ✅ Returns value | ✅ Returns `[undefined, value]` |
|
|
520
|
-
|
|
521
|
-
#### Use Cases for safe()
|
|
522
|
-
|
|
523
|
-
**1. Parsing/Validation that might fail:**
|
|
524
|
-
|
|
525
|
-
```typescript
|
|
526
|
-
const parsedConfig$ = derived(({ read, safe }) => {
|
|
527
|
-
const [err, config] = safe(() => {
|
|
528
|
-
const raw = read(rawConfig$);
|
|
529
|
-
return JSON.parse(raw); // Can throw SyntaxError
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
if (err) {
|
|
533
|
-
return { valid: false, error: "Invalid JSON" };
|
|
534
|
-
}
|
|
535
|
-
return { valid: true, config };
|
|
536
|
-
});
|
|
537
|
-
```
|
|
538
|
-
|
|
539
|
-
**2. Graceful degradation with multiple sources:**
|
|
540
|
-
|
|
541
|
-
```typescript
|
|
542
|
-
const dashboard$ = derived(({ read, safe }) => {
|
|
543
|
-
// Primary data - required
|
|
544
|
-
const user = read(user$);
|
|
545
|
-
|
|
546
|
-
// Optional data - graceful degradation
|
|
547
|
-
const [err1, analytics] = safe(() => read(analytics$));
|
|
548
|
-
const [err2, notifications] = safe(() => read(notifications$));
|
|
549
|
-
|
|
550
|
-
return {
|
|
551
|
-
user,
|
|
552
|
-
analytics: err1 ? null : analytics,
|
|
553
|
-
notifications: err2 ? [] : notifications,
|
|
554
|
-
errors: [err1, err2].filter(Boolean),
|
|
555
|
-
};
|
|
556
|
-
});
|
|
557
|
-
```
|
|
558
|
-
|
|
559
|
-
**3. Error handling in effects:**
|
|
560
|
-
|
|
561
|
-
```typescript
|
|
562
|
-
effect(({ read, safe }) => {
|
|
563
|
-
const [err, data] = safe(() => {
|
|
564
|
-
const raw = read(asyncData$);
|
|
565
|
-
return transformData(raw);
|
|
566
|
-
});
|
|
567
|
-
|
|
568
|
-
if (err) {
|
|
569
|
-
console.error("Effect failed:", err);
|
|
570
|
-
return; // Skip the rest of the effect
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
saveToLocalStorage(data);
|
|
574
|
-
});
|
|
575
|
-
```
|
|
576
|
-
|
|
577
|
-
**4. Error handling in React components:**
|
|
578
|
-
|
|
579
|
-
```tsx
|
|
580
|
-
function UserProfile() {
|
|
581
|
-
const result = useSelector(({ read, safe }) => {
|
|
582
|
-
const [err, user] = safe(() => read(user$));
|
|
583
|
-
return { err, user };
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
if (result.err) {
|
|
587
|
-
return <ErrorMessage error={result.err} />;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
return <Profile user={result.user} />;
|
|
591
|
-
}
|
|
592
|
-
```
|
|
593
|
-
|
|
594
|
-
**5. With rx() for inline error handling:**
|
|
595
|
-
|
|
596
|
-
```tsx
|
|
597
|
-
<Suspense fallback={<Loading />}>
|
|
598
|
-
{rx(({ read, safe }) => {
|
|
599
|
-
const [err, posts] = safe(() => read(posts$));
|
|
600
|
-
if (err) return <ErrorBanner message="Failed to load posts" />;
|
|
601
|
-
return posts.map((post) => <PostCard key={post.id} post={post} />);
|
|
602
|
-
})}
|
|
603
|
-
</Suspense>
|
|
604
|
-
```
|
|
605
|
-
|
|
606
|
-
### SelectContext Methods: Synchronous Only
|
|
607
|
-
|
|
608
|
-
All context methods (`read`, `all`, `race`, `any`, `settled`, `safe`) must be called **synchronously** during selector execution. They cannot be used in async callbacks like `setTimeout`, `Promise.then`, or event handlers.
|
|
609
|
-
|
|
610
|
-
```typescript
|
|
611
|
-
// ❌ WRONG - Calling read() in async callback
|
|
612
|
-
derived(({ read }) => {
|
|
613
|
-
setTimeout(() => {
|
|
614
|
-
read(atom$); // Error: called outside selection context
|
|
615
|
-
}, 100);
|
|
616
|
-
return "value";
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
// ❌ WRONG - Storing read() for later use
|
|
620
|
-
let savedRead;
|
|
621
|
-
select(({ read }) => {
|
|
622
|
-
savedRead = read; // Don't do this!
|
|
623
|
-
return read(atom$);
|
|
624
|
-
});
|
|
625
|
-
savedRead(atom$); // Error: called outside selection context
|
|
626
|
-
|
|
627
|
-
// ✅ CORRECT - For async access, use atom.get() directly
|
|
628
|
-
effect(({ read }) => {
|
|
629
|
-
const config = read(config$);
|
|
630
|
-
|
|
631
|
-
setTimeout(async () => {
|
|
632
|
-
// Use atom.get() for async access (not tracked as dependency)
|
|
633
|
-
const data = myMutableAtom$.get();
|
|
634
|
-
const asyncData = await myDerivedAtom$.get();
|
|
635
|
-
console.log(data, asyncData);
|
|
636
|
-
}, 100);
|
|
637
|
-
});
|
|
638
|
-
```
|
|
639
|
-
|
|
640
|
-
**Why this restriction?**
|
|
641
|
-
|
|
642
|
-
1. **Dependency tracking**: Context methods track which atoms are accessed to know when to recompute. This tracking only works during synchronous execution.
|
|
643
|
-
|
|
644
|
-
2. **Predictable behavior**: If `read()` could be called at any time, the reactive graph would be unpredictable and hard to debug.
|
|
645
|
-
|
|
646
|
-
3. **Clear error messages**: Rather than silently failing to track dependencies, atomirx throws a helpful error explaining the issue.
|
|
647
|
-
|
|
648
|
-
**For async access**, use `atom.get()` directly:
|
|
649
|
-
|
|
650
|
-
- `mutableAtom$.get()` - Returns the raw value (may be a Promise)
|
|
651
|
-
- `await derivedAtom$.get()` - Returns a Promise that resolves to the computed value
|
|
652
|
-
|
|
653
|
-
### Complete Example: Todo App with Async
|
|
654
|
-
|
|
655
|
-
```typescript
|
|
656
|
-
import { atom, derived } from "atomirx";
|
|
657
|
-
import { useSelector, rx } from "atomirx/react";
|
|
658
|
-
import { Suspense } from "react";
|
|
659
|
-
|
|
660
|
-
// Atoms store values (including Promises)
|
|
661
|
-
const filter$ = atom<"all" | "active" | "completed">("all");
|
|
662
|
-
const todoList$ = atom(() => fetchAllTodos()); // Lazy init, re-runs on reset()
|
|
663
|
-
|
|
664
|
-
// Derived handles reactive transformations (auto-unwraps Promises)
|
|
665
|
-
const filteredTodoList$ = derived(({ read }) => {
|
|
666
|
-
const filter = read(filter$);
|
|
667
|
-
const todoList = read(todoList$); // This is the resolved value, not a Promise!
|
|
668
|
-
|
|
669
|
-
switch (filter) {
|
|
670
|
-
case "active": return todoList.filter(t => !t.completed);
|
|
671
|
-
case "completed": return todoList.filter(t => t.completed);
|
|
672
|
-
default: return todoList;
|
|
673
|
-
}
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
// In UI - useSelector suspends until data is ready
|
|
677
|
-
function TodoList() {
|
|
678
|
-
const filteredTodoList = useSelector(filteredTodoList$);
|
|
679
|
-
|
|
680
|
-
return (
|
|
681
|
-
<ul>
|
|
682
|
-
{filteredTodoList.map(todo => (
|
|
683
|
-
<li key={todo.id}>{todo.text}</li>
|
|
684
|
-
))}
|
|
685
|
-
</ul>
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// Or use rx() for inline reactive rendering
|
|
690
|
-
function App() {
|
|
691
|
-
return (
|
|
692
|
-
<Suspense fallback={<div>Loading todos...</div>}>
|
|
693
|
-
{rx(({ read }) =>
|
|
694
|
-
read(filteredTodoList$).map(todo => <Todo key={todo.id} todo={todo} />)
|
|
695
|
-
)}
|
|
696
|
-
</Suspense>
|
|
697
|
-
);
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// Refetch todos
|
|
701
|
-
function RefreshButton() {
|
|
702
|
-
return (
|
|
703
|
-
<button onClick={() => todoList$.reset()}>
|
|
704
|
-
Refresh
|
|
705
|
-
</button>
|
|
706
|
-
);
|
|
707
|
-
}
|
|
708
|
-
```
|
|
709
|
-
|
|
710
|
-
### Deferred Entity Loading with `ready()`
|
|
711
|
-
|
|
712
|
-
When building detail pages (e.g., `/article/:id`), you often need to:
|
|
713
|
-
|
|
714
|
-
1. Wait for a route parameter to be set
|
|
715
|
-
2. Fetch data based on that parameter
|
|
716
|
-
3. Share the loaded entity across multiple components
|
|
717
|
-
|
|
718
|
-
The `ready()` method in derived atoms provides an elegant solution for this pattern.
|
|
719
|
-
|
|
720
|
-
#### The Pattern
|
|
721
|
-
|
|
722
|
-
```typescript
|
|
723
|
-
import { atom, derived, effect, readonly, define } from "atomirx";
|
|
724
|
-
|
|
725
|
-
const articleModule = define(() => {
|
|
726
|
-
// Current article ID - set from route
|
|
727
|
-
const currentArticleId$ = atom<string | undefined>(undefined);
|
|
728
|
-
|
|
729
|
-
// Article cache - normalized storage
|
|
730
|
-
const articleCache$ = atom<Record<string, Article>>({});
|
|
731
|
-
|
|
732
|
-
// Current article - uses ready() to wait for both ID and cached data
|
|
733
|
-
const currentArticle$ = derived(({ ready }) => {
|
|
734
|
-
const id = ready(currentArticleId$); // Suspends if undefined
|
|
735
|
-
const article = ready(articleCache$, (cache) => cache[id]); // Suspends if not cached
|
|
736
|
-
return article;
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
// Fetch article when ID changes
|
|
740
|
-
effect(({ read }) => {
|
|
741
|
-
const id = read(currentArticleId$);
|
|
742
|
-
if (!id) return;
|
|
743
|
-
|
|
744
|
-
// Skip if already cached
|
|
745
|
-
const cache = read(articleCache$);
|
|
746
|
-
if (cache[id]) return;
|
|
747
|
-
|
|
748
|
-
// Fetch and cache
|
|
749
|
-
// ─────────────────────────────────────────────────────────────
|
|
750
|
-
// Optional: Track loading/error states in cache for more control
|
|
751
|
-
// type CacheEntry<T> =
|
|
752
|
-
// | { status: "loading" }
|
|
753
|
-
// | { status: "error"; error: Error }
|
|
754
|
-
// | { status: "success"; data: T };
|
|
755
|
-
// const articleCache$ = atom<Record<string, CacheEntry<Article>>>({});
|
|
756
|
-
//
|
|
757
|
-
// articleCache$.set((prev) => ({ ...prev, [id]: { status: "loading" } }));
|
|
758
|
-
// fetch(...)
|
|
759
|
-
// .then((article) => articleCache$.set((prev) => ({
|
|
760
|
-
// ...prev, [id]: { status: "success", data: article }
|
|
761
|
-
// })))
|
|
762
|
-
// .catch((error) => articleCache$.set((prev) => ({
|
|
763
|
-
// ...prev, [id]: { status: "error", error }
|
|
764
|
-
// })));
|
|
765
|
-
// ─────────────────────────────────────────────────────────────
|
|
766
|
-
fetch(`/api/articles/${id}`)
|
|
767
|
-
.then((r) => r.json())
|
|
768
|
-
.then((article) => {
|
|
769
|
-
articleCache$.set((prev) => ({ ...prev, [id]: article }));
|
|
770
|
-
});
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
return {
|
|
774
|
-
...readonly({ currentArticleId$, currentArticle$ }),
|
|
775
|
-
|
|
776
|
-
// ─────────────────────────────────────────────────────────────
|
|
777
|
-
// navigateTo(id) - Navigate to a new article
|
|
778
|
-
// ─────────────────────────────────────────────────────────────
|
|
779
|
-
// Flow:
|
|
780
|
-
// 1. Set currentArticleId$ to new ID
|
|
781
|
-
// 2. currentArticle$ recomputes:
|
|
782
|
-
// - If cached: ready() succeeds → UI shows article immediately
|
|
783
|
-
// - If not cached: ready() suspends → UI shows <Skeleton />
|
|
784
|
-
// 3. effect() runs in parallel:
|
|
785
|
-
// - If cached: early return (no fetch)
|
|
786
|
-
// - If not cached: fetch → update articleCache$
|
|
787
|
-
// 4. When cache updated, currentArticle$ recomputes → UI shows article
|
|
788
|
-
navigateTo: (id: string) => currentArticleId$.set(id),
|
|
789
|
-
|
|
790
|
-
// ─────────────────────────────────────────────────────────────
|
|
791
|
-
// invalidate(id) - Mark an article as stale (soft invalidation)
|
|
792
|
-
// ─────────────────────────────────────────────────────────────
|
|
793
|
-
// Flow:
|
|
794
|
-
// 1. Guard: Skip if id === currentArticleId (don't disrupt current view)
|
|
795
|
-
// 2. Remove article from cache
|
|
796
|
-
// 3. No immediate effect on UI (current article unchanged)
|
|
797
|
-
// 4. Next navigateTo(id) will trigger fresh fetch
|
|
798
|
-
// Use case: Background sync detected article was updated elsewhere
|
|
799
|
-
invalidate: (id: string) => {
|
|
800
|
-
if (id === currentArticleId$.get()) return;
|
|
801
|
-
articleCache$.set((prev) => {
|
|
802
|
-
const { [id]: _, ...rest } = prev;
|
|
803
|
-
return rest;
|
|
804
|
-
});
|
|
805
|
-
},
|
|
806
|
-
|
|
807
|
-
// ─────────────────────────────────────────────────────────────
|
|
808
|
-
// refresh() - Force refetch current article (hard refresh)
|
|
809
|
-
// ─────────────────────────────────────────────────────────────
|
|
810
|
-
// Flow:
|
|
811
|
-
// 1. Remove current article from cache
|
|
812
|
-
// 2. currentArticle$ recomputes:
|
|
813
|
-
// - ready() suspends (cache miss) → UI shows <Skeleton />
|
|
814
|
-
// 3. effect() runs:
|
|
815
|
-
// - Cache miss detected → fetch starts
|
|
816
|
-
// 4. Fetch completes → cache updated → UI shows fresh data
|
|
817
|
-
// Use case: Pull-to-refresh, retry after error
|
|
818
|
-
refresh() {
|
|
819
|
-
articleCache$.set((prev) => {
|
|
820
|
-
const { [currentArticleId$.get()]: _, ...rest } = prev;
|
|
821
|
-
return rest;
|
|
822
|
-
});
|
|
823
|
-
},
|
|
824
|
-
};
|
|
825
|
-
});
|
|
826
|
-
```
|
|
827
|
-
|
|
828
|
-
#### How It Works
|
|
829
|
-
|
|
830
|
-
```
|
|
831
|
-
┌─────────────────────────────────────────────────────────────┐
|
|
832
|
-
│ Route: /article/:id │
|
|
833
|
-
│ → navigateTo(id) │
|
|
834
|
-
└─────────────────────────────────────────────────────────────┘
|
|
835
|
-
│
|
|
836
|
-
▼
|
|
837
|
-
┌─────────────────────────────────────────────────────────────┐
|
|
838
|
-
│ effect() detects ID change │
|
|
839
|
-
│ → Checks cache, fetches if not cached │
|
|
840
|
-
│ → Updates articleCache$ │
|
|
841
|
-
└─────────────────────────────────────────────────────────────┘
|
|
842
|
-
│
|
|
843
|
-
▼
|
|
844
|
-
┌─────────────────────────────────────────────────────────────┐
|
|
845
|
-
│ currentArticle$ (derived with ready()) │
|
|
846
|
-
│ → Suspends until ID is set AND article is in cache │
|
|
847
|
-
│ → Returns article when both conditions met │
|
|
848
|
-
└─────────────────────────────────────────────────────────────┘
|
|
849
|
-
│
|
|
850
|
-
▼
|
|
851
|
-
┌─────────────────────────────────────────────────────────────┐
|
|
852
|
-
│ Components with Suspense │
|
|
853
|
-
│ → Show loading fallback while suspended │
|
|
854
|
-
│ → Render article when ready │
|
|
855
|
-
└─────────────────────────────────────────────────────────────┘
|
|
856
|
-
```
|
|
857
|
-
|
|
858
|
-
#### Component Usage
|
|
859
|
-
|
|
860
|
-
```tsx
|
|
861
|
-
// Page component - syncs route and provides boundaries
|
|
862
|
-
export const ArticlePage = () => {
|
|
863
|
-
const { id } = useParams();
|
|
864
|
-
const { navigateTo } = articleModule();
|
|
865
|
-
|
|
866
|
-
useEffect(() => {
|
|
867
|
-
navigateTo(id);
|
|
868
|
-
}, [id]);
|
|
869
|
-
|
|
870
|
-
return (
|
|
871
|
-
<ErrorBoundary fallback={<ErrorDialog />}>
|
|
872
|
-
<Suspense fallback={<ArticleSkeleton />}>
|
|
873
|
-
<ArticleHeader />
|
|
874
|
-
<ArticleContent />
|
|
875
|
-
<ArticleMeta />
|
|
876
|
-
</Suspense>
|
|
877
|
-
</ErrorBoundary>
|
|
878
|
-
);
|
|
879
|
-
};
|
|
880
|
-
|
|
881
|
-
// Child components - clean, no loading/error handling needed
|
|
882
|
-
export const ArticleHeader = () => {
|
|
883
|
-
const { currentArticle$ } = articleModule();
|
|
884
|
-
const article = useSelector(currentArticle$);
|
|
885
|
-
|
|
886
|
-
return <h1>{article.title}</h1>;
|
|
887
|
-
};
|
|
888
|
-
|
|
889
|
-
export const ArticleContent = () => {
|
|
890
|
-
const { currentArticle$ } = articleModule();
|
|
891
|
-
const article = useSelector(currentArticle$);
|
|
892
|
-
|
|
893
|
-
return <div className="content">{article.body}</div>;
|
|
894
|
-
};
|
|
895
|
-
|
|
896
|
-
export const ArticleMeta = () => {
|
|
897
|
-
const { currentArticle$ } = articleModule();
|
|
898
|
-
const article = useSelector(currentArticle$);
|
|
899
|
-
|
|
900
|
-
return (
|
|
901
|
-
<span>
|
|
902
|
-
By {article.author} • {article.date}
|
|
903
|
-
</span>
|
|
904
|
-
);
|
|
905
|
-
};
|
|
906
|
-
```
|
|
907
|
-
|
|
908
|
-
#### Benefits
|
|
909
|
-
|
|
910
|
-
| Benefit | Description |
|
|
911
|
-
| ------------------------ | ---------------------------------------------------------------- |
|
|
912
|
-
| **Clean components** | Child components just read the atom - no loading/error handling |
|
|
913
|
-
| **No prop drilling** | All components access `currentArticle$` directly from the module |
|
|
914
|
-
| **Automatic suspension** | `ready()` handles the "wait for data" logic declaratively |
|
|
915
|
-
| **Centralized fetching** | Effect handles when/how to fetch, components just consume |
|
|
916
|
-
| **Cache management** | Normalized cache enables invalidation and updates |
|
|
917
|
-
|
|
918
|
-
#### When to Use `ready()`
|
|
919
|
-
|
|
920
|
-
| Use Case | Example |
|
|
921
|
-
| -------------------- | ------------------------------------------------ |
|
|
922
|
-
| Route-based entities | `/article/:id`, `/user/:userId`, `/product/:sku` |
|
|
923
|
-
| Auth-gated content | Wait for `currentUser$` before showing dashboard |
|
|
924
|
-
| Dependent data | Wait for parent entity before fetching children |
|
|
925
|
-
| Multi-step forms | Wait for previous step data before showing next |
|
|
926
|
-
|
|
927
|
-
#### Important Notes
|
|
928
|
-
|
|
929
|
-
- **Only use in `derived()` or `effect()`** - `ready()` suspends computation, which only works in reactive contexts
|
|
930
|
-
- **Separate fetching from derivation** - Use `effect()` for side effects (fetching), `derived()` for computing values
|
|
931
|
-
- **Read from cache in effect** - Don't read `currentArticle$` in the effect that populates it; read `articleCache$` directly
|
|
932
|
-
|
|
933
|
-
## Usage Guide
|
|
934
|
-
|
|
935
|
-
### Atoms: The Foundation
|
|
936
|
-
|
|
937
|
-
Atoms are the building blocks of atomirx. They hold mutable state and automatically notify subscribers when the value changes.
|
|
938
|
-
|
|
939
|
-
#### Creating Atoms
|
|
940
|
-
|
|
941
|
-
```typescript
|
|
942
|
-
import { atom } from "atomirx";
|
|
943
|
-
|
|
944
|
-
// Synchronous atom with initial value
|
|
945
|
-
const count$ = atom(0);
|
|
946
|
-
const user$ = atom<User | null>(null);
|
|
947
|
-
const settings$ = atom({ theme: "dark", language: "en" });
|
|
948
|
-
|
|
949
|
-
// Async atom - automatically tracks loading/error states
|
|
950
|
-
const userData$ = atom(fetchUser(userId));
|
|
951
|
-
|
|
952
|
-
// Lazy initialization - computation deferred until first access
|
|
953
|
-
const expensive$ = atom(() => computeExpensiveValue());
|
|
954
|
-
|
|
955
|
-
// With fallback value during loading/error
|
|
956
|
-
const posts$ = atom(fetchPosts(), { fallback: [] });
|
|
957
|
-
```
|
|
958
|
-
|
|
959
|
-
#### Reading Atom Values
|
|
960
|
-
|
|
961
|
-
```typescript
|
|
962
|
-
import { getAtomState, isPending } from "atomirx";
|
|
963
|
-
|
|
964
|
-
// Direct access (outside reactive context)
|
|
965
|
-
console.log(count$.get()); // Current value (T or Promise<T>)
|
|
966
|
-
|
|
967
|
-
// Check atom state
|
|
968
|
-
const state = getAtomState(userData$);
|
|
969
|
-
if (state.status === "loading") {
|
|
970
|
-
console.log("Loading...");
|
|
971
|
-
} else if (state.status === "error") {
|
|
972
|
-
console.log("Error:", state.error);
|
|
973
|
-
} else {
|
|
974
|
-
console.log("User:", state.value);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
// Quick loading check
|
|
978
|
-
console.log(isPending(userData$.get())); // true while Promise is pending
|
|
979
|
-
```
|
|
980
|
-
|
|
981
|
-
#### Updating Atoms
|
|
982
|
-
|
|
983
|
-
```typescript
|
|
984
|
-
// Direct value
|
|
985
|
-
count$.set(10);
|
|
986
|
-
|
|
987
|
-
// Functional update (receives current value)
|
|
988
|
-
count$.set((current) => current + 1);
|
|
989
|
-
|
|
990
|
-
// Async update
|
|
991
|
-
userData$.set(fetchUser(newUserId));
|
|
992
|
-
|
|
993
|
-
// Reset to initial state
|
|
994
|
-
count$.reset();
|
|
995
|
-
```
|
|
996
|
-
|
|
997
|
-
#### Subscribing to Changes
|
|
998
|
-
|
|
999
|
-
```typescript
|
|
1000
|
-
// Subscribe to changes
|
|
1001
|
-
const unsubscribe = count$.on((newValue) => {
|
|
1002
|
-
console.log("Count changed to:", newValue);
|
|
1003
|
-
});
|
|
1004
|
-
|
|
1005
|
-
// Await async atoms
|
|
1006
|
-
await userData$;
|
|
1007
|
-
console.log("User loaded:", userData$.get());
|
|
1008
|
-
|
|
1009
|
-
// Unsubscribe when done
|
|
1010
|
-
unsubscribe();
|
|
1011
|
-
```
|
|
1012
|
-
|
|
1013
|
-
#### Complete Atom API
|
|
1014
|
-
|
|
1015
|
-
**MutableAtom** (created by `atom()`):
|
|
1016
|
-
|
|
1017
|
-
| Property/Method | Type | Description |
|
|
1018
|
-
| --------------- | ------------ | -------------------------------------------------- |
|
|
1019
|
-
| `get()` | `T` | Current value (may be a Promise for async atoms) |
|
|
1020
|
-
| `set(value)` | `void` | Update with value, Promise, or updater function |
|
|
1021
|
-
| `reset()` | `void` | Reset to initial value |
|
|
1022
|
-
| `on(listener)` | `() => void` | Subscribe to changes, returns unsubscribe function |
|
|
1023
|
-
|
|
1024
|
-
**DerivedAtom** (created by `derived()`):
|
|
1025
|
-
|
|
1026
|
-
| Property/Method | Type | Description |
|
|
1027
|
-
| --------------- | ---------------- | ---------------------------------------------- |
|
|
1028
|
-
| `get()` | `Promise<T>` | Always returns a Promise |
|
|
1029
|
-
| `staleValue` | `T \| undefined` | Fallback or last resolved value during loading |
|
|
1030
|
-
| `state()` | `AtomState<T>` | Current state (ready/error/loading) |
|
|
1031
|
-
| `refresh()` | `void` | Re-run the computation |
|
|
1032
|
-
| `on(listener)` | `() => void` | Subscribe to changes, returns unsubscribe |
|
|
1033
|
-
|
|
1034
|
-
**AtomState** (returned by `state()` or `getAtomState()`):
|
|
1035
|
-
|
|
1036
|
-
```typescript
|
|
1037
|
-
type AtomState<T> =
|
|
1038
|
-
| { status: "ready"; value: T }
|
|
1039
|
-
| { status: "error"; error: unknown }
|
|
1040
|
-
| { status: "loading"; promise: Promise<T> };
|
|
1041
|
-
```
|
|
1042
|
-
|
|
1043
|
-
### Derived State: Computed Values
|
|
1044
|
-
|
|
1045
|
-
Derived atoms automatically compute values based on other atoms. They track dependencies at runtime and only recompute when those specific dependencies change.
|
|
1046
|
-
|
|
1047
|
-
> **Note:** For error handling in derived selectors, use `safe()` instead of try/catch. See [Error Handling: Use `safe()` Not try/catch](#error-handling-use-safe-not-trycatch).
|
|
1048
|
-
|
|
1049
|
-
#### Basic Derived State
|
|
1050
|
-
|
|
1051
|
-
```typescript
|
|
1052
|
-
import { atom, derived } from "atomirx";
|
|
1053
|
-
|
|
1054
|
-
const firstName$ = atom("John");
|
|
1055
|
-
const lastName$ = atom("Doe");
|
|
1056
|
-
|
|
1057
|
-
// Derived state with automatic dependency tracking
|
|
1058
|
-
const fullName$ = derived(({ read }) => {
|
|
1059
|
-
return `${read(firstName$)} ${read(lastName$)}`;
|
|
1060
|
-
});
|
|
1061
|
-
|
|
1062
|
-
// Derived atoms always return Promise<T> for .get()
|
|
1063
|
-
await fullName$.get(); // "John Doe"
|
|
1064
|
-
|
|
1065
|
-
// Or use staleValue for synchronous access (after first resolution)
|
|
1066
|
-
fullName$.staleValue; // "John Doe" (or undefined before first resolution)
|
|
1067
|
-
|
|
1068
|
-
// Check state
|
|
1069
|
-
fullName$.state(); // { status: "ready", value: "John Doe" }
|
|
1070
|
-
|
|
1071
|
-
firstName$.set("Jane");
|
|
1072
|
-
await fullName$.get(); // "Jane Doe"
|
|
1073
|
-
```
|
|
1074
|
-
|
|
1075
|
-
#### Conditional Dependencies
|
|
1076
|
-
|
|
1077
|
-
One of atomirx's most powerful features is **conditional dependency tracking**. Dependencies are tracked based on actual runtime access, not static analysis:
|
|
1078
|
-
|
|
1079
|
-
```typescript
|
|
1080
|
-
const showDetails$ = atom(false);
|
|
1081
|
-
const summary$ = atom("Brief overview");
|
|
1082
|
-
const details$ = atom("Detailed information...");
|
|
1083
|
-
|
|
1084
|
-
const content$ = derived(({ read }) => {
|
|
1085
|
-
// Only tracks showDetails$ initially
|
|
1086
|
-
if (read(showDetails$)) {
|
|
1087
|
-
// details$ becomes a dependency only when showDetails$ is true
|
|
1088
|
-
return read(details$);
|
|
1089
|
-
}
|
|
1090
|
-
return read(summary$);
|
|
1091
|
-
});
|
|
1092
|
-
|
|
1093
|
-
// When showDetails$ is false:
|
|
1094
|
-
// - Changes to details$ do NOT trigger recomputation
|
|
1095
|
-
// - Only changes to showDetails$ or summary$ trigger recomputation
|
|
1096
|
-
```
|
|
1097
|
-
|
|
1098
|
-
#### Suspense-Style Getters
|
|
1099
|
-
|
|
1100
|
-
The `read()` function follows React Suspense semantics for async atoms:
|
|
1101
|
-
|
|
1102
|
-
| Atom State | `read()` Behavior |
|
|
1103
|
-
| ---------- | ----------------------------------------------- |
|
|
1104
|
-
| Loading | Throws the Promise (caught by derived/Suspense) |
|
|
1105
|
-
| Error | Throws the error |
|
|
1106
|
-
| Ready | Returns the value |
|
|
1107
|
-
|
|
1108
|
-
```typescript
|
|
1109
|
-
const user$ = atom(fetchUser());
|
|
1110
|
-
|
|
1111
|
-
const userName$ = derived(({ read }) => {
|
|
1112
|
-
// Automatically handles loading/error states
|
|
1113
|
-
const user = read(user$);
|
|
1114
|
-
return user.name;
|
|
1115
|
-
});
|
|
1116
|
-
|
|
1117
|
-
// Check state
|
|
1118
|
-
const state = userName$.state();
|
|
1119
|
-
if (state.status === "loading") {
|
|
1120
|
-
console.log("Loading...");
|
|
1121
|
-
} else if (state.status === "error") {
|
|
1122
|
-
console.log("Error:", state.error);
|
|
1123
|
-
} else {
|
|
1124
|
-
console.log("User name:", state.value);
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
// Or use staleValue with a fallback
|
|
1128
|
-
const userName = derived(({ read }) => read(user$).name, { fallback: "Guest" });
|
|
1129
|
-
userName.staleValue; // "Guest" during loading, then actual name
|
|
1130
|
-
```
|
|
1131
|
-
|
|
1132
|
-
#### Derived from Multiple Async Sources
|
|
1133
|
-
|
|
1134
|
-
```typescript
|
|
1135
|
-
const user$ = atom(fetchUser());
|
|
1136
|
-
const posts$ = atom(fetchPosts());
|
|
1137
|
-
|
|
1138
|
-
const dashboard$ = derived(({ read }) => {
|
|
1139
|
-
const user = read(user$); // Suspends if loading
|
|
1140
|
-
const posts = read(posts$); // Suspends if loading
|
|
1141
|
-
|
|
1142
|
-
return {
|
|
1143
|
-
userName: user.name,
|
|
1144
|
-
postCount: posts.length,
|
|
1145
|
-
};
|
|
1146
|
-
});
|
|
1147
|
-
|
|
1148
|
-
// Check state
|
|
1149
|
-
const state = dashboard$.state();
|
|
1150
|
-
// state.status is "loading" until BOTH user$ and posts$ resolve
|
|
1151
|
-
|
|
1152
|
-
// Or use all() for explicit parallel loading
|
|
1153
|
-
const dashboard2$ = derived(({ all }) => {
|
|
1154
|
-
const [user, posts] = all([user$, posts$]);
|
|
1155
|
-
return { userName: user.name, postCount: posts.length };
|
|
1156
|
-
});
|
|
1157
|
-
```
|
|
1158
|
-
|
|
1159
|
-
### Effects: Side Effect Management
|
|
1160
|
-
|
|
1161
|
-
Effects run side effects whenever their dependencies change. They use the same reactive context as `derived()`.
|
|
1162
|
-
|
|
1163
|
-
> **Note:** For error handling in effects, use `safe()` instead of try/catch. See [Error Handling: Use `safe()` Not try/catch](#error-handling-use-safe-not-trycatch).
|
|
1164
|
-
|
|
1165
|
-
#### Basic Effects
|
|
1166
|
-
|
|
1167
|
-
```typescript
|
|
1168
|
-
import { atom, effect } from "atomirx";
|
|
1169
|
-
|
|
1170
|
-
const count$ = atom(0);
|
|
1171
|
-
|
|
1172
|
-
// Effect runs immediately and on every change
|
|
1173
|
-
const dispose = effect(({ read }) => {
|
|
1174
|
-
console.log("Count is now:", read(count$));
|
|
1175
|
-
});
|
|
1176
|
-
|
|
1177
|
-
count$.set(5); // Logs: "Count is now: 5"
|
|
1178
|
-
|
|
1179
|
-
// Clean up when done
|
|
1180
|
-
dispose();
|
|
1181
|
-
```
|
|
1182
|
-
|
|
1183
|
-
#### Effects with Cleanup
|
|
1184
|
-
|
|
1185
|
-
Use `onCleanup()` to register cleanup functions that run before the next execution or on dispose:
|
|
1186
|
-
|
|
1187
|
-
```typescript
|
|
1188
|
-
const interval$ = atom(1000);
|
|
1189
|
-
|
|
1190
|
-
const dispose = effect(({ read, onCleanup }) => {
|
|
1191
|
-
const ms = read(interval$);
|
|
1192
|
-
const id = setInterval(() => console.log("tick"), ms);
|
|
1193
|
-
|
|
1194
|
-
// Cleanup runs before next execution or on dispose
|
|
1195
|
-
onCleanup(() => clearInterval(id));
|
|
1196
|
-
});
|
|
1197
|
-
|
|
1198
|
-
interval$.set(500); // Clears old interval, starts new one
|
|
1199
|
-
dispose(); // Clears interval completely
|
|
1200
|
-
```
|
|
1201
|
-
|
|
1202
|
-
#### Effects with Multiple Dependencies
|
|
1203
|
-
|
|
1204
|
-
```typescript
|
|
1205
|
-
const user$ = atom<User | null>(null);
|
|
1206
|
-
const settings$ = atom({ notifications: true });
|
|
1207
|
-
|
|
1208
|
-
effect(({ read }) => {
|
|
1209
|
-
const user = read(user$);
|
|
1210
|
-
const settings = read(settings$);
|
|
1211
|
-
|
|
1212
|
-
if (user && settings.notifications) {
|
|
1213
|
-
analytics.identify(user.id);
|
|
1214
|
-
notifications.subscribe(user.id);
|
|
1215
|
-
}
|
|
1216
|
-
});
|
|
1217
|
-
```
|
|
1218
|
-
|
|
1219
|
-
### Async Patterns
|
|
1220
|
-
|
|
1221
|
-
atomirx provides powerful utilities for working with multiple async atoms through the `SelectContext`.
|
|
1222
|
-
|
|
1223
|
-
#### `all()` - Wait for All (like Promise.all)
|
|
1224
|
-
|
|
1225
|
-
```typescript
|
|
1226
|
-
const user$ = atom(fetchUser());
|
|
1227
|
-
const posts$ = atom(fetchPosts());
|
|
1228
|
-
const comments$ = atom(fetchComments());
|
|
3
|
+
# atomirx
|
|
1229
4
|
|
|
1230
|
-
|
|
1231
|
-
// Suspends until ALL atoms resolve (array-based)
|
|
1232
|
-
const [user, posts, comments] = all([user$, posts$, comments$]);
|
|
5
|
+
### Reactive State That Just Works
|
|
1233
6
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
#### `any()` - First Ready (like Promise.any)
|
|
1239
|
-
|
|
1240
|
-
```typescript
|
|
1241
|
-
const primaryApi$ = atom(fetchFromPrimary());
|
|
1242
|
-
const fallbackApi$ = atom(fetchFromFallback());
|
|
1243
|
-
|
|
1244
|
-
const data$ = derived(({ any }) => {
|
|
1245
|
-
// Returns first successfully resolved value (object-based, returns { key, value })
|
|
1246
|
-
const result = any({ primary: primaryApi$, fallback: fallbackApi$ });
|
|
1247
|
-
return result.value;
|
|
1248
|
-
});
|
|
1249
|
-
```
|
|
1250
|
-
|
|
1251
|
-
#### `race()` - First Settled (like Promise.race)
|
|
1252
|
-
|
|
1253
|
-
```typescript
|
|
1254
|
-
const cache$ = atom(checkCache());
|
|
1255
|
-
const api$ = atom(fetchFromApi());
|
|
1256
|
-
|
|
1257
|
-
const data$ = derived(({ race }) => {
|
|
1258
|
-
// Returns first settled (ready OR error) (object-based, returns { key, value })
|
|
1259
|
-
const result = race({ cache: cache$, api: api$ });
|
|
1260
|
-
return result.value;
|
|
1261
|
-
});
|
|
1262
|
-
```
|
|
1263
|
-
|
|
1264
|
-
#### `settled()` - All Results (like Promise.allSettled)
|
|
1265
|
-
|
|
1266
|
-
```typescript
|
|
1267
|
-
const user$ = atom(fetchUser());
|
|
1268
|
-
const posts$ = atom(fetchPosts());
|
|
1269
|
-
|
|
1270
|
-
const results$ = derived(({ settled }) => {
|
|
1271
|
-
// Returns status for each atom (array-based)
|
|
1272
|
-
const [userResult, postsResult] = settled([user$, posts$]);
|
|
7
|
+
[](https://www.npmjs.com/package/atomirx)
|
|
8
|
+
[](https://bundlephobia.com/package/atomirx)
|
|
9
|
+
[](https://www.typescriptlang.org/)
|
|
1273
10
|
|
|
1274
|
-
|
|
1275
|
-
user: userResult.status === "ready" ? userResult.value : null,
|
|
1276
|
-
posts: postsResult.status === "ready" ? postsResult.value : [],
|
|
1277
|
-
hasErrors: userResult.status === "error" || postsResult.status === "error",
|
|
1278
|
-
};
|
|
1279
|
-
});
|
|
1280
|
-
```
|
|
1281
|
-
|
|
1282
|
-
#### Async Utility Summary
|
|
1283
|
-
|
|
1284
|
-
| Utility | Input | Output | Behavior |
|
|
1285
|
-
| ----------- | --------------- | ------------------------ | ---------------------------------- |
|
|
1286
|
-
| `all()` | Array of atoms | Array of values | Suspends until all ready |
|
|
1287
|
-
| `any()` | Record of atoms | `{ key, value }` (first) | First to resolve wins |
|
|
1288
|
-
| `race()` | Record of atoms | `{ key, value }` (first) | First to settle (ready/error) wins |
|
|
1289
|
-
| `settled()` | Array of atoms | Array of SettledResult | Suspends until all settled |
|
|
11
|
+
**Atoms** · **Derived** · **Effects** · **Suspense** · **DevTools**
|
|
1290
12
|
|
|
1291
|
-
|
|
13
|
+
</div>
|
|
1292
14
|
|
|
1293
|
-
|
|
1294
|
-
type SettledResult<T> =
|
|
1295
|
-
| { status: "ready"; value: T }
|
|
1296
|
-
| { status: "error"; error: unknown };
|
|
1297
|
-
```
|
|
1298
|
-
|
|
1299
|
-
### Batching Updates
|
|
15
|
+
---
|
|
1300
16
|
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
import { atom, batch } from "atomirx";
|
|
17
|
+
```tsx
|
|
18
|
+
import { atom, derived } from "atomirx";
|
|
19
|
+
import { useSelector } from "atomirx/react";
|
|
1305
20
|
|
|
1306
|
-
const
|
|
1307
|
-
const
|
|
1308
|
-
const age$ = atom(30);
|
|
21
|
+
const count$ = atom(0);
|
|
22
|
+
const doubled$ = derived(({ read }) => read(count$) * 2);
|
|
1309
23
|
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
24
|
+
function App() {
|
|
25
|
+
// Read multiple atoms in one selector = one subscription, one re-render
|
|
26
|
+
const { count, doubled } = useSelector(({ read }) => ({
|
|
27
|
+
count: read(count$),
|
|
28
|
+
doubled: read(doubled$),
|
|
29
|
+
}));
|
|
1314
30
|
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
}
|
|
31
|
+
return (
|
|
32
|
+
<button onClick={() => count$.set((c) => c + 1)}>
|
|
33
|
+
{count} × 2 = {doubled}
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
1321
37
|
```
|
|
1322
38
|
|
|
1323
|
-
|
|
39
|
+
---
|
|
1324
40
|
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
```typescript
|
|
1328
|
-
import { emitter } from "atomirx";
|
|
41
|
+
## Why atomirx?
|
|
1329
42
|
|
|
1330
|
-
|
|
1331
|
-
|
|
43
|
+
| Feature | atomirx | Others (Recoil/Jotai/etc) |
|
|
44
|
+
| --------------------- | ---------------------------- | ------------------------------- |
|
|
45
|
+
| **Philosophy** | **Mutable & Synchronous** | often Immutable or Async-forced |
|
|
46
|
+
| **Simple atoms** | `atom(0)` | `atom(0)` |
|
|
47
|
+
| **Computed values** | `derived(({ read }) => ...)` | `atom((get) => ...)` |
|
|
48
|
+
| **Async first-class** | **Built-in Suspense** | Plugin / Add-on |
|
|
49
|
+
| **Side effects** | `effect(({ read }) => ...)` | often `useEffect` |
|
|
50
|
+
| **Leak-free Pools** | **Yes (Auto GC)** | Manual management |
|
|
51
|
+
| **DevTools** | **Zero-config** | Additional setup |
|
|
52
|
+
| **Bundle size** | **Tiny** | Varies |
|
|
1332
53
|
|
|
1333
|
-
|
|
1334
|
-
const unsubscribe = userEvents.on((event) => {
|
|
1335
|
-
console.log(`User ${event.userId} ${event.type}`);
|
|
1336
|
-
});
|
|
54
|
+
---
|
|
1337
55
|
|
|
1338
|
-
|
|
1339
|
-
userEvents.emit({ type: "login", userId: "123" });
|
|
56
|
+
## Install
|
|
1340
57
|
|
|
1341
|
-
|
|
1342
|
-
const appReady = emitter<Config>();
|
|
1343
|
-
appReady.settle(config); // All current AND future subscribers receive config
|
|
1344
|
-
```
|
|
58
|
+
<div align="center">
|
|
1345
59
|
|
|
1346
|
-
|
|
60
|
+
| Package Manager | Command |
|
|
61
|
+
| :-------------- | :-------------------- |
|
|
62
|
+
| **npm** | `npm install atomirx` |
|
|
63
|
+
| **pnpm** | `pnpm add atomirx` |
|
|
64
|
+
| **yarn** | `yarn add atomirx` |
|
|
65
|
+
| **bun** | `bun add atomirx` |
|
|
1347
66
|
|
|
1348
|
-
|
|
67
|
+
</div>
|
|
1349
68
|
|
|
1350
|
-
|
|
1351
|
-
import { define, atom } from "atomirx";
|
|
69
|
+
---
|
|
1352
70
|
|
|
1353
|
-
|
|
1354
|
-
const counterStore = define(() => {
|
|
1355
|
-
const count$ = atom(0);
|
|
71
|
+
## The Basics
|
|
1356
72
|
|
|
1357
|
-
|
|
1358
|
-
count$,
|
|
1359
|
-
increment: () => count$.set((c) => c + 1),
|
|
1360
|
-
decrement: () => count$.set((c) => c - 1),
|
|
1361
|
-
reset: () => count$.reset(),
|
|
1362
|
-
};
|
|
1363
|
-
});
|
|
73
|
+
### Atoms
|
|
1364
74
|
|
|
1365
|
-
|
|
1366
|
-
const store = counterStore();
|
|
1367
|
-
store.increment();
|
|
75
|
+
Reactive state containers - the foundation of everything.
|
|
1368
76
|
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
count$: atom(999),
|
|
1372
|
-
increment: vi.fn(),
|
|
1373
|
-
decrement: vi.fn(),
|
|
1374
|
-
reset: vi.fn(),
|
|
1375
|
-
}));
|
|
77
|
+
```ts
|
|
78
|
+
import { atom } from "atomirx";
|
|
1376
79
|
|
|
1377
|
-
|
|
1378
|
-
counterStore.reset();
|
|
1379
|
-
```
|
|
80
|
+
const count$ = atom(0);
|
|
1380
81
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
#### Extending AtomMeta with TypeScript
|
|
1386
|
-
|
|
1387
|
-
Use TypeScript's module augmentation to add custom properties to `AtomMeta`:
|
|
1388
|
-
|
|
1389
|
-
```typescript
|
|
1390
|
-
// Extend the meta interfaces with your custom properties
|
|
1391
|
-
declare module "atomirx" {
|
|
1392
|
-
// MutableAtomMeta - for atom() specific options
|
|
1393
|
-
interface MutableAtomMeta {
|
|
1394
|
-
/** Whether the atom should be persisted to localStorage */
|
|
1395
|
-
persisted?: boolean;
|
|
1396
|
-
/**
|
|
1397
|
-
* Custom validation function.
|
|
1398
|
-
* Return true to allow the update, false to reject it.
|
|
1399
|
-
*/
|
|
1400
|
-
validate?: (value: unknown) => boolean;
|
|
1401
|
-
/** Optional error handler for validation failures */
|
|
1402
|
-
onValidationError?: (value: unknown, key?: string) => void;
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
// DerivedAtomMeta - for derived() specific options
|
|
1406
|
-
interface DerivedAtomMeta {
|
|
1407
|
-
/** Custom cache key for memoization */
|
|
1408
|
-
cacheKey?: string;
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
// AtomMeta - base type, shared by both (key, etc.)
|
|
1412
|
-
// interface AtomMeta { ... }
|
|
1413
|
-
}
|
|
82
|
+
count$.get(); // Read current value
|
|
83
|
+
count$.set(5); // Set new value
|
|
84
|
+
count$.set(n => n + 1); // Update with reducer
|
|
85
|
+
count$.on(() => { ... }); // Subscribe to changes
|
|
1414
86
|
```
|
|
1415
87
|
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
The `onCreateHook` fires whenever an atom or module is created. Use the reducer pattern to compose multiple middlewares:
|
|
1419
|
-
|
|
1420
|
-
```typescript
|
|
1421
|
-
import { onCreateHook } from "atomirx";
|
|
1422
|
-
|
|
1423
|
-
// Persistence middleware
|
|
1424
|
-
onCreateHook.override((prev) => (info) => {
|
|
1425
|
-
// Call previous middleware first (composition)
|
|
1426
|
-
prev?.(info);
|
|
1427
|
-
|
|
1428
|
-
// Only handle mutable atoms with persisted flag
|
|
1429
|
-
if (info.type === "mutable" && info.meta?.persisted && info.meta?.key) {
|
|
1430
|
-
const storageKey = `my-app-${info.meta.key}`;
|
|
1431
|
-
|
|
1432
|
-
// Restore from localStorage on creation (if not dirty)
|
|
1433
|
-
if (!info.atom.dirty()) {
|
|
1434
|
-
const stored = localStorage.getItem(storageKey);
|
|
1435
|
-
if (stored) {
|
|
1436
|
-
try {
|
|
1437
|
-
info.atom.set(JSON.parse(stored));
|
|
1438
|
-
} catch {
|
|
1439
|
-
// Invalid JSON, ignore
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
// Save to localStorage on every change
|
|
1445
|
-
info.atom.on(() => {
|
|
1446
|
-
localStorage.setItem(storageKey, JSON.stringify(info.atom.get()));
|
|
1447
|
-
});
|
|
1448
|
-
}
|
|
1449
|
-
});
|
|
1450
|
-
```
|
|
88
|
+
### Derived
|
|
1451
89
|
|
|
1452
|
-
|
|
90
|
+
Computed values that auto-update when dependencies change.
|
|
1453
91
|
|
|
1454
|
-
|
|
92
|
+
```ts
|
|
93
|
+
import { atom, derived } from "atomirx";
|
|
1455
94
|
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
{ theme: "dark", language: "en" },
|
|
1460
|
-
{ meta: { key: "settings", persisted: true } }
|
|
95
|
+
// Automatically recomputes when firstName$ or lastName$ changes
|
|
96
|
+
const fullName$ = derived(
|
|
97
|
+
({ read }) => `${read(firstName$)} ${read(lastName$)}`,
|
|
1461
98
|
);
|
|
1462
99
|
|
|
1463
|
-
|
|
1464
|
-
settings$.set({ theme: "light", language: "en" });
|
|
1465
|
-
|
|
1466
|
-
// On next page load, value is restored from localStorage
|
|
100
|
+
await fullName$.get(); // "John Doe"
|
|
1467
101
|
```
|
|
1468
102
|
|
|
1469
|
-
|
|
103
|
+
### Effects
|
|
1470
104
|
|
|
1471
|
-
|
|
105
|
+
Side effects that run when dependencies change.
|
|
1472
106
|
|
|
1473
|
-
```
|
|
1474
|
-
|
|
1475
|
-
onCreateHook.override((prev) => (info) => {
|
|
1476
|
-
prev?.(info);
|
|
1477
|
-
console.log(
|
|
1478
|
-
`[atomirx] Created ${info.type}: ${info.meta?.key ?? "anonymous"}`
|
|
1479
|
-
);
|
|
1480
|
-
});
|
|
1481
|
-
|
|
1482
|
-
// Validation middleware - wraps set() to validate before applying
|
|
1483
|
-
onCreateHook.override((prev) => (info) => {
|
|
1484
|
-
prev?.(info);
|
|
1485
|
-
|
|
1486
|
-
if (info.type === "mutable" && info.meta?.validate) {
|
|
1487
|
-
const validate = info.meta.validate;
|
|
1488
|
-
const originalSet = info.atom.set.bind(info.atom);
|
|
1489
|
-
|
|
1490
|
-
// Wrap set() with validation
|
|
1491
|
-
info.atom.set = (valueOrReducer) => {
|
|
1492
|
-
// Resolve the next value (handle both direct value and reducer)
|
|
1493
|
-
const nextValue =
|
|
1494
|
-
typeof valueOrReducer === "function"
|
|
1495
|
-
? (valueOrReducer as (prev: unknown) => unknown)(info.atom.get())
|
|
1496
|
-
: valueOrReducer;
|
|
1497
|
-
|
|
1498
|
-
// Validate before applying
|
|
1499
|
-
if (!validate(nextValue)) {
|
|
1500
|
-
console.warn(
|
|
1501
|
-
`[atomirx] Validation failed for ${info.meta?.key}`,
|
|
1502
|
-
nextValue
|
|
1503
|
-
);
|
|
1504
|
-
return; // Reject the update
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
originalSet(valueOrReducer);
|
|
1508
|
-
};
|
|
1509
|
-
}
|
|
1510
|
-
});
|
|
1511
|
-
|
|
1512
|
-
// Usage: atom with validation
|
|
1513
|
-
const age$ = atom(25, {
|
|
1514
|
-
meta: {
|
|
1515
|
-
key: "age",
|
|
1516
|
-
validate: (value) =>
|
|
1517
|
-
typeof value === "number" && value >= 0 && value <= 150,
|
|
1518
|
-
},
|
|
1519
|
-
});
|
|
1520
|
-
|
|
1521
|
-
age$.set(30); // OK
|
|
1522
|
-
age$.set(-5); // Rejected: "Validation failed for age"
|
|
1523
|
-
age$.set(200); // Rejected: "Validation failed for age"
|
|
1524
|
-
```
|
|
107
|
+
```ts
|
|
108
|
+
import { effect } from "atomirx";
|
|
1525
109
|
|
|
1526
|
-
|
|
110
|
+
effect(({ read, onCleanup }) => {
|
|
111
|
+
const config = read(config$); // Subscribe to config changes
|
|
1527
112
|
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
prev?.(info);
|
|
113
|
+
// Set up connection based on reactive config
|
|
114
|
+
const socket = connectToSocket(config.url);
|
|
115
|
+
console.log("Connected to", config.url);
|
|
1532
116
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
117
|
+
// Cleanup runs before next run and on dispose
|
|
118
|
+
onCleanup(() => {
|
|
119
|
+
socket.disconnect();
|
|
120
|
+
console.log("Disconnected");
|
|
121
|
+
});
|
|
1537
122
|
});
|
|
1538
123
|
```
|
|
1539
124
|
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
The `onCreateHook` receives different info objects based on what's being created:
|
|
1543
|
-
|
|
1544
|
-
```typescript
|
|
1545
|
-
// Mutable atom
|
|
1546
|
-
interface MutableCreateInfo {
|
|
1547
|
-
type: "mutable";
|
|
1548
|
-
key: string | undefined;
|
|
1549
|
-
meta: AtomMeta | undefined;
|
|
1550
|
-
atom: MutableAtom<unknown>;
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
// Derived atom
|
|
1554
|
-
interface DerivedCreateInfo {
|
|
1555
|
-
type: "derived";
|
|
1556
|
-
key: string | undefined;
|
|
1557
|
-
meta: AtomMeta | undefined;
|
|
1558
|
-
atom: DerivedAtom<unknown, boolean>;
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
// Module (from define())
|
|
1562
|
-
interface ModuleCreateInfo {
|
|
1563
|
-
type: "module";
|
|
1564
|
-
key: string | undefined;
|
|
1565
|
-
meta: ModuleMeta | undefined;
|
|
1566
|
-
module: unknown;
|
|
1567
|
-
}
|
|
1568
|
-
```
|
|
1569
|
-
|
|
1570
|
-
## React Integration
|
|
1571
|
-
|
|
1572
|
-
atomirx provides first-class React integration through the `atomirx/react` package.
|
|
125
|
+
---
|
|
1573
126
|
|
|
1574
|
-
|
|
127
|
+
## React
|
|
1575
128
|
|
|
1576
|
-
|
|
129
|
+
### useSelector
|
|
1577
130
|
|
|
1578
|
-
|
|
131
|
+
Subscribe to atoms in React components.
|
|
1579
132
|
|
|
1580
133
|
```tsx
|
|
1581
134
|
import { useSelector } from "atomirx/react";
|
|
1582
|
-
import { atom } from "atomirx";
|
|
1583
|
-
|
|
1584
|
-
const count$ = atom(0);
|
|
1585
|
-
const user$ = atom<User | null>(null);
|
|
1586
135
|
|
|
1587
136
|
function Counter() {
|
|
1588
|
-
//
|
|
137
|
+
// Single atom - component re-renders when count$ changes
|
|
1589
138
|
const count = useSelector(count$);
|
|
1590
139
|
|
|
1591
|
-
//
|
|
140
|
+
// Selector function - derive values inline
|
|
1592
141
|
const doubled = useSelector(({ read }) => read(count$) * 2);
|
|
1593
142
|
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
});
|
|
1600
|
-
|
|
1601
|
-
return <div>{display}</div>;
|
|
143
|
+
return (
|
|
144
|
+
<span>
|
|
145
|
+
{count} / {doubled}
|
|
146
|
+
</span>
|
|
147
|
+
);
|
|
1602
148
|
}
|
|
1603
149
|
```
|
|
1604
150
|
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
```tsx
|
|
1608
|
-
// Only re-render when specific fields change
|
|
1609
|
-
const userName = useSelector(
|
|
1610
|
-
({ read }) => read(user$)?.name,
|
|
1611
|
-
(prev, next) => prev === next
|
|
1612
|
-
);
|
|
1613
|
-
```
|
|
1614
|
-
|
|
1615
|
-
#### Why useSelector is Powerful
|
|
1616
|
-
|
|
1617
|
-
`useSelector` provides a unified API that replaces multiple hooks from other libraries:
|
|
1618
|
-
|
|
1619
|
-
**One hook for all use cases:**
|
|
1620
|
-
|
|
1621
|
-
```tsx
|
|
1622
|
-
// 1. Single atom (shorthand)
|
|
1623
|
-
const count = useSelector(count$);
|
|
1624
|
-
|
|
1625
|
-
// 2. Derived value (selector)
|
|
1626
|
-
const doubled = useSelector(({ read }) => read(count$) * 2);
|
|
1627
|
-
|
|
1628
|
-
// 3. Multiple atoms (all) - array-based
|
|
1629
|
-
const [user, posts] = useSelector(({ all }) => all([user$, posts$]));
|
|
1630
|
-
|
|
1631
|
-
// 4. Loadable mode (state) - no Suspense needed
|
|
1632
|
-
const userState = useSelector(({ state }) => state(user$));
|
|
1633
|
-
// { status: "loading" | "ready" | "error", value, error }
|
|
1634
|
-
|
|
1635
|
-
// 5. Error handling (safe) - preserves Suspense
|
|
1636
|
-
const result = useSelector(({ read, safe }) => {
|
|
1637
|
-
const [err, data] = safe(() => JSON.parse(read(rawJson$)));
|
|
1638
|
-
return err ? { error: err.message } : { data };
|
|
1639
|
-
});
|
|
1640
|
-
|
|
1641
|
-
// 6. First ready (any), race, allSettled
|
|
1642
|
-
const fastest = useSelector(({ any }) => any({ cache: cache$, api: api$ }));
|
|
1643
|
-
const results = useSelector(({ settled }) => settled([a$, b$, c$]));
|
|
1644
|
-
```
|
|
1645
|
-
|
|
1646
|
-
**Comparison with other libraries:**
|
|
1647
|
-
|
|
1648
|
-
| Use Case | atomirx | Jotai | Recoil | Zustand |
|
|
1649
|
-
| ------------------- | ------------------------------------------ | ------------------------------ | ------------------------------- | -------------------------- |
|
|
1650
|
-
| Single atom | `useSelector(atom$)` | `useAtomValue(atom)` | `useRecoilValue(atom)` | `useStore(s => s.value)` |
|
|
1651
|
-
| Derived value | `useSelector(({ read }) => ...)` | `useAtomValue(derivedAtom)` | `useRecoilValue(selector)` | `useStore(s => derive(s))` |
|
|
1652
|
-
| Multiple atoms | `useSelector(({ all }) => all([a$, b$]))` | Multiple `useAtomValue` calls | Multiple `useRecoilValue` calls | Multiple selectors |
|
|
1653
|
-
| Suspense mode | Built-in (default) | Built-in | Built-in | Manual |
|
|
1654
|
-
| Loadable mode | `useSelector(({ state }) => state(atom$))` | `useAtomValue(loadable(atom))` | `useRecoilValueLoadable(atom)` | Manual |
|
|
1655
|
-
| Safe error handling | `safe()` in selector | Manual try/catch | Manual try/catch | Manual |
|
|
1656
|
-
| Custom equality | 2nd parameter | `selectAtom` | N/A | 2nd parameter |
|
|
1657
|
-
|
|
1658
|
-
**Key advantages:**
|
|
1659
|
-
|
|
1660
|
-
1. **Single unified hook** - No need to choose between `useAtomValue`, `useAtomValueLoadable`, etc.
|
|
1661
|
-
2. **Composable selectors** - Combine multiple atoms, derive values, handle errors in one selector
|
|
1662
|
-
3. **Flexible async modes** - Switch between Suspense and loadable without changing atoms
|
|
1663
|
-
4. **Built-in utilities** - `all()`, `any()`, `race()`, `settled()`, `safe()`, `state()` available in selector
|
|
1664
|
-
5. **Type-safe** - Full TypeScript inference across all patterns
|
|
1665
|
-
|
|
1666
|
-
**Boilerplate comparison - Loading multiple async atoms:**
|
|
1667
|
-
|
|
1668
|
-
```tsx
|
|
1669
|
-
// ❌ Jotai - Multiple hooks + loadable wrapper
|
|
1670
|
-
const userLoadable = useAtomValue(loadable(userAtom));
|
|
1671
|
-
const postsLoadable = useAtomValue(loadable(postsAtom));
|
|
1672
|
-
const isLoading =
|
|
1673
|
-
userLoadable.state === "loading" || postsLoadable.state === "loading";
|
|
1674
|
-
const user = userLoadable.state === "hasData" ? userLoadable.data : null;
|
|
1675
|
-
const posts = postsLoadable.state === "hasData" ? postsLoadable.data : [];
|
|
1676
|
-
|
|
1677
|
-
// ❌ Recoil - Multiple hooks
|
|
1678
|
-
const userLoadable = useRecoilValueLoadable(userAtom);
|
|
1679
|
-
const postsLoadable = useRecoilValueLoadable(postsAtom);
|
|
1680
|
-
const isLoading =
|
|
1681
|
-
userLoadable.state === "loading" || postsLoadable.state === "loading";
|
|
1682
|
-
const user = userLoadable.state === "hasValue" ? userLoadable.contents : null;
|
|
1683
|
-
const posts = postsLoadable.state === "hasValue" ? postsLoadable.contents : [];
|
|
1684
|
-
|
|
1685
|
-
// ✅ atomirx - One hook, one selector
|
|
1686
|
-
const { isLoading, user, posts } = useSelector(({ state }) => {
|
|
1687
|
-
const userState = state(user$);
|
|
1688
|
-
const postsState = state(posts$);
|
|
1689
|
-
return {
|
|
1690
|
-
isLoading:
|
|
1691
|
-
userState.status === "loading" || postsState.status === "loading",
|
|
1692
|
-
user: userState.value,
|
|
1693
|
-
posts: postsState.value ?? [],
|
|
1694
|
-
};
|
|
1695
|
-
});
|
|
1696
|
-
```
|
|
1697
|
-
|
|
1698
|
-
### Reactive Components with rx
|
|
1699
|
-
|
|
1700
|
-
The `rx()` function creates inline reactive components for fine-grained updates.
|
|
151
|
+
### rx - Inline Reactive
|
|
1701
152
|
|
|
1702
|
-
|
|
153
|
+
Fine-grained updates without re-rendering the parent component.
|
|
1703
154
|
|
|
1704
155
|
```tsx
|
|
1705
156
|
import { rx } from "atomirx/react";
|
|
1706
157
|
|
|
1707
|
-
function
|
|
158
|
+
function App() {
|
|
1708
159
|
return (
|
|
1709
160
|
<div>
|
|
1710
161
|
{/* Only this span re-renders when count$ changes */}
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
{/* Derived value */}
|
|
1714
|
-
<span>Doubled: {rx(({ read }) => read(count$) * 2)}</span>
|
|
1715
|
-
|
|
1716
|
-
{/* Complex rendering */}
|
|
1717
|
-
{rx(({ read }) => {
|
|
1718
|
-
const user = read(user$);
|
|
1719
|
-
return user ? <UserCard user={user} /> : <LoginPrompt />;
|
|
1720
|
-
})}
|
|
1721
|
-
|
|
1722
|
-
{/* Async with utilities */}
|
|
1723
|
-
{rx(({ all }) => {
|
|
1724
|
-
const [user, posts] = all([user$, posts$]);
|
|
1725
|
-
return <Feed user={user} posts={posts} />;
|
|
1726
|
-
})}
|
|
162
|
+
Count: {rx(count$)}
|
|
163
|
+
Double: {rx(({ read }) => read(count$) * 2)}
|
|
1727
164
|
</div>
|
|
1728
165
|
);
|
|
1729
166
|
}
|
|
1730
167
|
```
|
|
1731
168
|
|
|
1732
|
-
|
|
169
|
+
---
|
|
1733
170
|
|
|
1734
|
-
|
|
171
|
+
## Async Atoms
|
|
1735
172
|
|
|
1736
|
-
|
|
173
|
+
First-class async support with React Suspense.
|
|
1737
174
|
|
|
1738
175
|
```tsx
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
<div>
|
|
1742
|
-
{/* With loading handler - no Suspense needed */}
|
|
1743
|
-
{rx(userData$, {
|
|
1744
|
-
loading: () => <Spinner />,
|
|
1745
|
-
})}
|
|
1746
|
-
|
|
1747
|
-
{/* With error handler - no ErrorBoundary needed */}
|
|
1748
|
-
{rx(userData$, {
|
|
1749
|
-
error: ({ error }) => <Alert>{String(error)}</Alert>,
|
|
1750
|
-
})}
|
|
1751
|
-
|
|
1752
|
-
{/* Both handlers - fully self-contained */}
|
|
1753
|
-
{rx(userData$, {
|
|
1754
|
-
loading: () => <UserSkeleton />,
|
|
1755
|
-
error: ({ error }) => <UserError error={error} />,
|
|
1756
|
-
})}
|
|
1757
|
-
|
|
1758
|
-
{/* With selector and options */}
|
|
1759
|
-
{rx(({ read }) => read(posts$).slice(0, 5), {
|
|
1760
|
-
loading: () => <PostsSkeleton count={5} />,
|
|
1761
|
-
error: ({ error }) => <PostsError onRetry={() => posts$.refresh()} />,
|
|
1762
|
-
equals: "shallow",
|
|
1763
|
-
})}
|
|
1764
|
-
</div>
|
|
1765
|
-
);
|
|
1766
|
-
}
|
|
1767
|
-
```
|
|
1768
|
-
|
|
1769
|
-
**When to use each approach:**
|
|
1770
|
-
|
|
1771
|
-
| Approach | Use When |
|
|
1772
|
-
| ----------------------------- | ------------------------------------------------------ |
|
|
1773
|
-
| `rx()` with Suspense | Shared loading UI across multiple components |
|
|
1774
|
-
| `rx()` with `loading`/`error` | Self-contained component with custom inline UI |
|
|
1775
|
-
| `rx()` with `state()` | Complex conditional rendering based on multiple states |
|
|
1776
|
-
|
|
1777
|
-
#### Selector Memoization with `deps`
|
|
1778
|
-
|
|
1779
|
-
By default, function selectors are recreated on every render. Use `deps` to control memoization:
|
|
176
|
+
// Atom holds a Promise - Suspense handles loading state
|
|
177
|
+
const user$ = atom(fetchUser());
|
|
1780
178
|
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
return
|
|
1784
|
-
<div>
|
|
1785
|
-
{/* No memoization (default) - selector recreated every render */}
|
|
1786
|
-
{rx(({ read }) => read(count$) * 2)}
|
|
1787
|
-
{/* Stable forever - selector never recreated */}
|
|
1788
|
-
{rx(({ read }) => read(count$) * 2, { deps: [] })}
|
|
1789
|
-
{/* Recreate when multiplier changes */}
|
|
1790
|
-
{rx(({ read }) => read(count$) * multiplier, { deps: [multiplier] })}
|
|
1791
|
-
{/* Atom shorthand is always stable by reference */}
|
|
1792
|
-
{rx(count$)} {/* No deps needed */}
|
|
1793
|
-
</div>
|
|
1794
|
-
);
|
|
179
|
+
function Profile() {
|
|
180
|
+
const user = useSelector(user$); // Suspends until resolved
|
|
181
|
+
return <h1>{user.name}</h1>;
|
|
1795
182
|
}
|
|
1796
|
-
```
|
|
1797
|
-
|
|
1798
|
-
**Memoization behavior:**
|
|
1799
|
-
|
|
1800
|
-
| Input | `deps` | Behavior |
|
|
1801
|
-
| ------------------------------- | ----------- | --------------------------------- |
|
|
1802
|
-
| Atom (`rx(atom$)`) | (ignored) | Always memoized by atom reference |
|
|
1803
|
-
| Function (`rx(({ read }) => …`) | `undefined` | No memoization (recreated always) |
|
|
1804
|
-
| Function | `[]` | Stable forever (never recreated) |
|
|
1805
|
-
| Function | `[a, b]` | Recreated when deps change |
|
|
1806
|
-
|
|
1807
|
-
### Async Actions with useAction
|
|
1808
|
-
|
|
1809
|
-
Handle async operations with built-in loading/error states:
|
|
1810
|
-
|
|
1811
|
-
```tsx
|
|
1812
|
-
import { useAction } from "atomirx/react";
|
|
1813
|
-
|
|
1814
|
-
function UserProfile({ userId }: { userId: string }) {
|
|
1815
|
-
const saveUser = useAction(async ({ signal }, data: UserData) => {
|
|
1816
|
-
const response = await fetch(`/api/users/${userId}`, {
|
|
1817
|
-
method: "PUT",
|
|
1818
|
-
body: JSON.stringify(data),
|
|
1819
|
-
signal, // Automatic abort on unmount or re-execution
|
|
1820
|
-
});
|
|
1821
|
-
return response.json();
|
|
1822
|
-
});
|
|
1823
183
|
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
saveUser(formData);
|
|
1829
|
-
}}
|
|
1830
|
-
>
|
|
1831
|
-
{saveUser.status === "loading" && <Spinner />}
|
|
1832
|
-
{saveUser.status === "error" && <Error message={saveUser.error} />}
|
|
1833
|
-
{saveUser.status === "success" && <Success data={saveUser.result} />}
|
|
1834
|
-
|
|
1835
|
-
<button disabled={saveUser.status === "loading"}>Save</button>
|
|
1836
|
-
</form>
|
|
1837
|
-
);
|
|
1838
|
-
}
|
|
184
|
+
// Wrap with Suspense for loading fallback
|
|
185
|
+
<Suspense fallback={<Loading />}>
|
|
186
|
+
<Profile />
|
|
187
|
+
</Suspense>;
|
|
1839
188
|
```
|
|
1840
189
|
|
|
1841
|
-
|
|
190
|
+
### Multiple Async
|
|
1842
191
|
|
|
1843
|
-
|
|
1844
|
-
// Execute immediately on mount
|
|
1845
|
-
const fetchUser = useAction(
|
|
1846
|
-
async ({ signal }) => fetchUserApi(userId, { signal }),
|
|
1847
|
-
{ lazy: false, deps: [userId] }
|
|
1848
|
-
);
|
|
192
|
+
Wait for multiple atoms with `all()`.
|
|
1849
193
|
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
194
|
+
```ts
|
|
195
|
+
derived(({ read, all }) => {
|
|
196
|
+
// Waits for both to resolve (like Promise.all)
|
|
197
|
+
const [user, posts] = all([user$, posts$]);
|
|
198
|
+
return { user, posts };
|
|
199
|
+
});
|
|
1855
200
|
```
|
|
1856
201
|
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
| Property/Method | Type | Description |
|
|
1860
|
-
| --------------- | --------------------------------------------- | ---------------------------- |
|
|
1861
|
-
| `status` | `'idle' \| 'loading' \| 'success' \| 'error'` | Current state |
|
|
1862
|
-
| `result` | `T \| undefined` | Result value when successful |
|
|
1863
|
-
| `error` | `unknown` | Error when failed |
|
|
1864
|
-
| `abort()` | `() => void` | Cancel current request |
|
|
1865
|
-
| `reset()` | `() => void` | Reset to idle state |
|
|
1866
|
-
|
|
1867
|
-
### Reference Stability with useStable
|
|
202
|
+
---
|
|
1868
203
|
|
|
1869
|
-
|
|
204
|
+
## Pools (Memory-Safe Families)
|
|
1870
205
|
|
|
1871
|
-
|
|
1872
|
-
import { useStable } from "atomirx/react";
|
|
1873
|
-
|
|
1874
|
-
function SearchResults({ query, filters }: Props) {
|
|
1875
|
-
const stable = useStable({
|
|
1876
|
-
// Object - stable if shallow equal
|
|
1877
|
-
searchParams: { query, ...filters },
|
|
206
|
+
Parameterized atoms with automatic garbage collection.
|
|
1878
207
|
|
|
1879
|
-
|
|
1880
|
-
selectedIds: [1, 2, 3],
|
|
208
|
+
**Problem:** Standard "atom families" (atoms created per ID) often leak memory because they leave orphan atoms behind when no longer needed.
|
|
1881
209
|
|
|
1882
|
-
|
|
1883
|
-
onSelect: (id: number) => {
|
|
1884
|
-
console.log("Selected:", id);
|
|
1885
|
-
},
|
|
1886
|
-
});
|
|
210
|
+
**Solution:** `atomirx` Pools automatically garbage collect entries that haven't been used for a specific time (`gcTime`).
|
|
1887
211
|
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
performSearch(stable.searchParams);
|
|
1891
|
-
}, [stable.searchParams]);
|
|
212
|
+
```ts
|
|
213
|
+
import { pool } from "atomirx";
|
|
1892
214
|
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
)
|
|
1896
|
-
}
|
|
215
|
+
// Create a pool - atoms are created lazily per params
|
|
216
|
+
const userPool = pool(
|
|
217
|
+
(id: string) => fetchUser(id), // Factory function
|
|
218
|
+
{ gcTime: 60_000 }, // Auto-cleanup after 60s of inactivity
|
|
219
|
+
);
|
|
1897
220
|
```
|
|
1898
221
|
|
|
1899
|
-
###
|
|
222
|
+
### Usage in Components
|
|
1900
223
|
|
|
1901
|
-
|
|
224
|
+
Use the `from` helper in `useSelector` (or `derived`/`effect`) to safely access pool atoms.
|
|
1902
225
|
|
|
1903
226
|
```tsx
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
227
|
+
function UserCard({ id }) {
|
|
228
|
+
// ✅ Safe: "from" ensures the atom is marked as used
|
|
229
|
+
const user = useSelector(({ read, from }) => {
|
|
230
|
+
const user$ = from(userPool, id);
|
|
231
|
+
return read(user$);
|
|
232
|
+
});
|
|
1910
233
|
|
|
1911
|
-
function UserProfile() {
|
|
1912
|
-
// Suspends until user$ resolves
|
|
1913
|
-
const user = useSelector(user$);
|
|
1914
234
|
return <div>{user.name}</div>;
|
|
1915
235
|
}
|
|
1916
|
-
|
|
1917
|
-
function App() {
|
|
1918
|
-
return (
|
|
1919
|
-
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
|
1920
|
-
<Suspense fallback={<div>Loading...</div>}>
|
|
1921
|
-
<UserProfile />
|
|
1922
|
-
</Suspense>
|
|
1923
|
-
</ErrorBoundary>
|
|
1924
|
-
);
|
|
1925
|
-
}
|
|
1926
|
-
```
|
|
1927
|
-
|
|
1928
|
-
#### Nested Suspense Boundaries
|
|
1929
|
-
|
|
1930
|
-
```tsx
|
|
1931
|
-
function Dashboard() {
|
|
1932
|
-
return (
|
|
1933
|
-
<div>
|
|
1934
|
-
<Suspense fallback={<HeaderSkeleton />}>
|
|
1935
|
-
<Header /> {/* Depends on user$ */}
|
|
1936
|
-
</Suspense>
|
|
1937
|
-
|
|
1938
|
-
<Suspense fallback={<FeedSkeleton />}>
|
|
1939
|
-
<Feed /> {/* Depends on posts$ */}
|
|
1940
|
-
</Suspense>
|
|
1941
|
-
|
|
1942
|
-
<Suspense fallback={<SidebarSkeleton />}>
|
|
1943
|
-
<Sidebar /> {/* Depends on recommendations$ */}
|
|
1944
|
-
</Suspense>
|
|
1945
|
-
</div>
|
|
1946
|
-
);
|
|
1947
|
-
}
|
|
1948
|
-
```
|
|
1949
|
-
|
|
1950
|
-
## API Reference
|
|
1951
|
-
|
|
1952
|
-
### Core API
|
|
1953
|
-
|
|
1954
|
-
#### `atom<T>(initialValue, options?)`
|
|
1955
|
-
|
|
1956
|
-
Creates a mutable reactive atom.
|
|
1957
|
-
|
|
1958
|
-
```typescript
|
|
1959
|
-
function atom<T>(
|
|
1960
|
-
initialValue: T | Promise<T> | (() => T | Promise<T>),
|
|
1961
|
-
options?: { fallback?: T }
|
|
1962
|
-
): MutableAtom<T>;
|
|
1963
|
-
```
|
|
1964
|
-
|
|
1965
|
-
#### `derived<T>(selector)`
|
|
1966
|
-
|
|
1967
|
-
Creates a read-only derived atom.
|
|
1968
|
-
|
|
1969
|
-
```typescript
|
|
1970
|
-
function derived<T>(selector: (context: SelectContext) => T): Atom<T>;
|
|
1971
|
-
|
|
1972
|
-
// Legacy array API (backward compatible)
|
|
1973
|
-
function derived<T, S>(source: Atom<S>, selector: (get: () => S) => T): Atom<T>;
|
|
1974
|
-
|
|
1975
|
-
function derived<T, S extends readonly Atom<any>[]>(
|
|
1976
|
-
sources: S,
|
|
1977
|
-
selector: (...getters: GetterTuple<S>) => T
|
|
1978
|
-
): Atom<T>;
|
|
1979
|
-
```
|
|
1980
|
-
|
|
1981
|
-
#### `effect(fn, options?)`
|
|
1982
|
-
|
|
1983
|
-
Creates a side effect that runs when dependencies change.
|
|
1984
|
-
|
|
1985
|
-
```typescript
|
|
1986
|
-
interface EffectContext extends SelectContext {
|
|
1987
|
-
onCleanup: (cleanup: VoidFunction) => void;
|
|
1988
|
-
onError: (handler: (error: unknown) => void) => void;
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
function effect(
|
|
1992
|
-
fn: (context: EffectContext) => void,
|
|
1993
|
-
options?: { onError?: (error: Error) => void }
|
|
1994
|
-
): () => void; // Returns dispose function
|
|
1995
|
-
```
|
|
1996
|
-
|
|
1997
|
-
#### `batch(fn)`
|
|
1998
|
-
|
|
1999
|
-
Batches multiple updates into a single notification.
|
|
2000
|
-
|
|
2001
|
-
```typescript
|
|
2002
|
-
function batch<T>(fn: () => T): T;
|
|
2003
|
-
```
|
|
2004
|
-
|
|
2005
|
-
#### `emitter<T>()`
|
|
2006
|
-
|
|
2007
|
-
Creates a pub/sub event emitter.
|
|
2008
|
-
|
|
2009
|
-
```typescript
|
|
2010
|
-
function emitter<T>(): {
|
|
2011
|
-
on: (listener: (value: T) => void) => () => void;
|
|
2012
|
-
emit: (value: T) => void;
|
|
2013
|
-
settle: (value: T) => void;
|
|
2014
|
-
};
|
|
2015
|
-
```
|
|
2016
|
-
|
|
2017
|
-
#### `define<T>(factory, options?)`
|
|
2018
|
-
|
|
2019
|
-
Creates a swappable lazy singleton.
|
|
2020
|
-
|
|
2021
|
-
```typescript
|
|
2022
|
-
function define<T>(
|
|
2023
|
-
factory: () => T,
|
|
2024
|
-
options?: { eager?: boolean }
|
|
2025
|
-
): {
|
|
2026
|
-
(): T;
|
|
2027
|
-
override: (factory: () => T) => void;
|
|
2028
|
-
reset: () => void;
|
|
2029
|
-
};
|
|
2030
|
-
```
|
|
2031
|
-
|
|
2032
|
-
#### `isAtom(value)`
|
|
2033
|
-
|
|
2034
|
-
Type guard for atoms.
|
|
2035
|
-
|
|
2036
|
-
```typescript
|
|
2037
|
-
function isAtom<T>(value: unknown): value is Atom<T>;
|
|
2038
|
-
```
|
|
2039
|
-
|
|
2040
|
-
### SelectContext API
|
|
2041
|
-
|
|
2042
|
-
Available in `derived()`, `effect()`, `useSelector()`, and `rx()`:
|
|
2043
|
-
|
|
2044
|
-
| Method | Signature | Description |
|
|
2045
|
-
| --------- | ----------------------------------------- | ---------------------------------------------------- |
|
|
2046
|
-
| `read` | `<T>(atom: Atom<T>) => Awaited<T>` | Read atom value with dependency tracking |
|
|
2047
|
-
| `ready` | `<T>(atom: Atom<T>) => NonNullable<T>` | Wait for non-null value (only in derived/effect) |
|
|
2048
|
-
| `all` | `(atoms[]) => values[]` | Wait for all atoms (Promise.all semantics) |
|
|
2049
|
-
| `any` | `({ key: atom }) => { key, value }` | First ready value (Promise.any semantics) |
|
|
2050
|
-
| `race` | `({ key: atom }) => { key, value }` | First settled value (Promise.race semantics) |
|
|
2051
|
-
| `settled` | `(atoms[]) => SettledResult[]` | All results (Promise.allSettled semantics) |
|
|
2052
|
-
| `state` | `(atom \| selector) => SelectStateResult` | Get async state without throwing (equality-friendly) |
|
|
2053
|
-
| `safe` | `(fn) => [error, result]` | Catch errors, preserve Suspense |
|
|
2054
|
-
|
|
2055
|
-
**Behavior:**
|
|
2056
|
-
|
|
2057
|
-
- `read()`: Returns value if ready, throws error if error, throws Promise if loading
|
|
2058
|
-
- `ready()`: Returns non-null value, suspends if null/undefined (only in derived/effect)
|
|
2059
|
-
- `all()`: Suspends until all atoms are ready, throws on first error
|
|
2060
|
-
- `any()`: Returns first ready value, throws AggregateError if all error
|
|
2061
|
-
- `race()`: Returns first settled (ready or error)
|
|
2062
|
-
- `settled()`: Returns `{ status: "ready", value }` or `{ status: "error", error }` for each atom
|
|
2063
|
-
- `state()`: Returns `AtomState<T>` without throwing - useful for inline state handling
|
|
2064
|
-
- `safe()`: Catches errors but re-throws Promises to preserve Suspense
|
|
2065
|
-
|
|
2066
|
-
#### `state()` - Get Async State Without Throwing
|
|
2067
|
-
|
|
2068
|
-
The `state()` method returns an `AtomState<T>` object instead of throwing. This is useful when you want to handle loading/error states inline without Suspense.
|
|
2069
|
-
|
|
2070
|
-
**Available at every level** - derived, rx, useSelector, effect:
|
|
2071
|
-
|
|
2072
|
-
```typescript
|
|
2073
|
-
// 1. In derived() - Build dashboard with partial loading
|
|
2074
|
-
const dashboard$ = derived(({ state }) => {
|
|
2075
|
-
const userState = state(user$);
|
|
2076
|
-
const postsState = state(posts$);
|
|
2077
|
-
|
|
2078
|
-
return {
|
|
2079
|
-
user: userState.status === 'ready' ? userState.value : null,
|
|
2080
|
-
posts: postsState.status === 'ready' ? postsState.value : [],
|
|
2081
|
-
isLoading: userState.status === 'loading' || postsState.status === 'loading',
|
|
2082
|
-
};
|
|
2083
|
-
});
|
|
2084
|
-
|
|
2085
|
-
// 2. In rx() - Inline loading/error UI
|
|
2086
|
-
{rx(({ state }) => {
|
|
2087
|
-
const dataState = state(data$);
|
|
2088
|
-
|
|
2089
|
-
if (dataState.status === 'loading') return <Spinner />;
|
|
2090
|
-
if (dataState.status === 'error') return <Error error={dataState.error} />;
|
|
2091
|
-
return <Content data={dataState.value} />;
|
|
2092
|
-
})}
|
|
2093
|
-
|
|
2094
|
-
// 3. In useSelector() - Get state object in component
|
|
2095
|
-
function Component() {
|
|
2096
|
-
const dataState = useSelector(({ state }) => state(asyncAtom$));
|
|
2097
|
-
|
|
2098
|
-
if (dataState.status === 'loading') return <Spinner />;
|
|
2099
|
-
// ...
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
// 4. In effect() - React to state changes
|
|
2103
|
-
effect(({ state }) => {
|
|
2104
|
-
const connState = state(connection$);
|
|
2105
|
-
|
|
2106
|
-
if (connState.status === 'ready') {
|
|
2107
|
-
console.log('Connected:', connState.value);
|
|
2108
|
-
}
|
|
2109
|
-
});
|
|
2110
|
-
|
|
2111
|
-
// 5. With combined operations
|
|
2112
|
-
const allData$ = derived(({ state, all }) => {
|
|
2113
|
-
const result = state(() => all([a$, b$, c$]));
|
|
2114
|
-
|
|
2115
|
-
if (result.status === 'loading') return { loading: true };
|
|
2116
|
-
if (result.status === 'error') return { error: result.error };
|
|
2117
|
-
return { data: result.value };
|
|
2118
|
-
});
|
|
2119
236
|
```
|
|
2120
237
|
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
| Method | On Loading | On Error | On Ready |
|
|
2124
|
-
| --------- | ------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |
|
|
2125
|
-
| `read()` | Throws Promise (Suspense) | Throws Error | Returns value |
|
|
2126
|
-
| `state()` | Returns `{ status: "loading", value: undefined, error: undefined }` | Returns `{ status: "error", value: undefined, error }` | Returns `{ status: "ready", value, error: undefined }` |
|
|
238
|
+
---
|
|
2127
239
|
|
|
2128
|
-
|
|
240
|
+
## DevTools
|
|
2129
241
|
|
|
2130
|
-
|
|
242
|
+
Atomirx comes with a powerful DevTools suite for debugging.
|
|
2131
243
|
|
|
2132
|
-
|
|
244
|
+
### 1. Setup (App Entry)
|
|
2133
245
|
|
|
2134
|
-
|
|
2135
|
-
// Shorthand
|
|
2136
|
-
function useSelector<T>(atom: Atom<T>): T;
|
|
246
|
+
Initialize the devtools registry in your app's entry point (e.g., `main.tsx` or `index.ts`).
|
|
2137
247
|
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
selector: (context: SelectContext) => T,
|
|
2141
|
-
equals?: (prev: T, next: T) => boolean
|
|
2142
|
-
): T;
|
|
2143
|
-
```
|
|
248
|
+
```ts
|
|
249
|
+
import { setupDevtools } from "atomirx/devtools";
|
|
2144
250
|
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
// Shorthand
|
|
2149
|
-
function rx<T>(atom: Atom<T>): ReactNode;
|
|
2150
|
-
function rx<T>(atom: Atom<T>, options?: RxOptions<T>): ReactNode;
|
|
2151
|
-
|
|
2152
|
-
// Context selector
|
|
2153
|
-
function rx<T>(selector: (context: SelectContext) => T): ReactNode;
|
|
2154
|
-
function rx<T>(
|
|
2155
|
-
selector: (context: SelectContext) => T,
|
|
2156
|
-
options?: Equality<T> | RxOptions<T>
|
|
2157
|
-
): ReactNode;
|
|
2158
|
-
|
|
2159
|
-
interface RxOptions<T> {
|
|
2160
|
-
/** Equality function for value comparison */
|
|
2161
|
-
equals?: Equality<T>;
|
|
2162
|
-
/** Render function for loading state (wraps with Suspense) */
|
|
2163
|
-
loading?: () => ReactNode;
|
|
2164
|
-
/** Render function for error state (wraps with ErrorBoundary) */
|
|
2165
|
-
error?: (props: { error: unknown }) => ReactNode;
|
|
2166
|
-
/** Dependencies for selector memoization */
|
|
2167
|
-
deps?: unknown[];
|
|
251
|
+
// Enable devtools tracking
|
|
252
|
+
if (process.env.NODE_ENV === "development") {
|
|
253
|
+
setupDevtools();
|
|
2168
254
|
}
|
|
2169
255
|
```
|
|
2170
256
|
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
```tsx
|
|
2174
|
-
// No memoization (default) - selector recreated every render
|
|
2175
|
-
rx(({ read }) => read(count$) * multiplier);
|
|
2176
|
-
|
|
2177
|
-
// Stable forever - never recreated
|
|
2178
|
-
rx(({ read }) => read(count$) * 2, { deps: [] });
|
|
2179
|
-
|
|
2180
|
-
// Recreate when multiplier changes
|
|
2181
|
-
rx(({ read }) => read(count$) * multiplier, { deps: [multiplier] });
|
|
2182
|
-
|
|
2183
|
-
// Atom shorthand is always stable (deps ignored)
|
|
2184
|
-
rx(count$);
|
|
2185
|
-
```
|
|
257
|
+
### 2. React UI Component
|
|
2186
258
|
|
|
2187
|
-
|
|
259
|
+
Add the `<DevToolsPanel />` to your root component.
|
|
2188
260
|
|
|
2189
261
|
```tsx
|
|
2190
|
-
|
|
2191
|
-
{
|
|
2192
|
-
rx(asyncAtom$, {
|
|
2193
|
-
loading: () => <Spinner />,
|
|
2194
|
-
});
|
|
2195
|
-
}
|
|
2196
|
-
|
|
2197
|
-
// Error handler only
|
|
2198
|
-
{
|
|
2199
|
-
rx(asyncAtom$, {
|
|
2200
|
-
error: ({ error }) => <Alert>{String(error)}</Alert>,
|
|
2201
|
-
});
|
|
2202
|
-
}
|
|
262
|
+
import { DevToolsPanel } from "atomirx/react-devtools";
|
|
2203
263
|
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
error: ({ error }) => <ErrorMessage error={error} />,
|
|
2209
|
-
});
|
|
2210
|
-
}
|
|
264
|
+
function App() {
|
|
265
|
+
return (
|
|
266
|
+
<>
|
|
267
|
+
<YourApp />
|
|
2211
268
|
|
|
2212
|
-
|
|
2213
|
-
{
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
});
|
|
269
|
+
{/* Renders a floating dockable panel */}
|
|
270
|
+
{process.env.NODE_ENV === "development" && (
|
|
271
|
+
<DevToolsPanel defaultPosition="bottom-right" />
|
|
272
|
+
)}
|
|
273
|
+
</>
|
|
274
|
+
);
|
|
2219
275
|
}
|
|
2220
276
|
```
|
|
2221
277
|
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
| Options | Loading State | Error State | Ready State |
|
|
2225
|
-
| -------------------- | ------------------ | ---------------- | ------------ |
|
|
2226
|
-
| No options | Suspense | ErrorBoundary | Render value |
|
|
2227
|
-
| `{ loading }` | Render `loading()` | ErrorBoundary | Render value |
|
|
2228
|
-
| `{ error }` | Suspense | Render `error()` | Render value |
|
|
2229
|
-
| `{ loading, error }` | Render `loading()` | Render `error()` | Render value |
|
|
2230
|
-
|
|
2231
|
-
#### `useAction`
|
|
2232
|
-
|
|
2233
|
-
```typescript
|
|
2234
|
-
function useAction<T, Args extends any[]>(
|
|
2235
|
-
action: (context: { signal: AbortSignal }, ...args: Args) => Promise<T>,
|
|
2236
|
-
options?: {
|
|
2237
|
-
lazy?: boolean;
|
|
2238
|
-
deps?: (Atom<any> | any)[];
|
|
2239
|
-
}
|
|
2240
|
-
): {
|
|
2241
|
-
(...args: Args): void;
|
|
2242
|
-
status: "idle" | "loading" | "success" | "error";
|
|
2243
|
-
result: T | undefined;
|
|
2244
|
-
error: unknown;
|
|
2245
|
-
abort: () => void;
|
|
2246
|
-
reset: () => void;
|
|
2247
|
-
};
|
|
2248
|
-
```
|
|
278
|
+
---
|
|
2249
279
|
|
|
2250
|
-
|
|
280
|
+
## API Reference
|
|
2251
281
|
|
|
2252
|
-
|
|
2253
|
-
function useStable<T extends Record<string, any>>(
|
|
2254
|
-
input: T,
|
|
2255
|
-
equals?: (prev: T[keyof T], next: T[keyof T]) => boolean
|
|
2256
|
-
): T;
|
|
2257
|
-
```
|
|
282
|
+
### Core (`atomirx`)
|
|
2258
283
|
|
|
2259
|
-
|
|
284
|
+
- `atom(initialValue, options?)`: Create a mutable atom.
|
|
285
|
+
- `derived(calculator, options?)`: Create a computed atom.
|
|
286
|
+
- `effect(runner, options?)`: Run side effects.
|
|
287
|
+
- `pool(factory, options)`: Create a garbage-collected atom pool.
|
|
288
|
+
- `batch(fn)`: Batch multiple updates.
|
|
2260
289
|
|
|
2261
|
-
|
|
290
|
+
### React (`atomirx/react`)
|
|
2262
291
|
|
|
2263
|
-
|
|
2264
|
-
|
|
292
|
+
- `useSelector(atom | selector)`: Subscribe to state.
|
|
293
|
+
- `rx(atom | selector)`: Renderless component for fine-grained updates.
|
|
2265
294
|
|
|
2266
|
-
|
|
2267
|
-
const count$ = atom(0); // MutableAtom<number>
|
|
2268
|
-
const name$ = atom("John"); // MutableAtom<string>
|
|
2269
|
-
const doubled$ = derived(({ read }) => read(count$) * 2); // Atom<number>
|
|
295
|
+
### Select Context (inside `derived`, `effect`, `useSelector`)
|
|
2270
296
|
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
}
|
|
297
|
+
- `read(atom)`: Get value and subscribe.
|
|
298
|
+
- `from(pool, params)`: Get atom from pool safely.
|
|
299
|
+
- `all([atoms])`: Wait for all promises.
|
|
300
|
+
- `race({ key: atom })`: Race promises.
|
|
301
|
+
- `state(atom)`: Get `{ status, value, error }` without suspending.
|
|
2277
302
|
|
|
2278
|
-
|
|
2279
|
-
const userData$ = atom<User>(fetchUser()); // MutableAtom<User>
|
|
303
|
+
---
|
|
2280
304
|
|
|
2281
|
-
|
|
2282
|
-
const userName$ = derived(({ read }) => {
|
|
2283
|
-
const user = read(user$);
|
|
2284
|
-
return user?.name ?? "Anonymous"; // Atom<string>
|
|
2285
|
-
});
|
|
305
|
+
## CLI
|
|
2286
306
|
|
|
2287
|
-
|
|
2288
|
-
function createListAtom<T>(initial: T[] = []) {
|
|
2289
|
-
const items$ = atom<T[]>(initial);
|
|
307
|
+
### Install AI Skills (Cursor IDE)
|
|
2290
308
|
|
|
2291
|
-
|
|
2292
|
-
items$,
|
|
2293
|
-
add: (item: T) => items$.set((list) => [...list, item]),
|
|
2294
|
-
remove: (predicate: (item: T) => boolean) =>
|
|
2295
|
-
items$.set((list) => list.filter((item) => !predicate(item))),
|
|
2296
|
-
};
|
|
2297
|
-
}
|
|
309
|
+
atomirx includes AI skills for Cursor IDE to help you write better code with AI assistance.
|
|
2298
310
|
|
|
2299
|
-
|
|
311
|
+
```bash
|
|
312
|
+
npx atomirx add-skill
|
|
2300
313
|
```
|
|
2301
314
|
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
| Feature | atomirx | Redux Toolkit | Zustand | Jotai | Recoil |
|
|
2305
|
-
| -------------------- | ----------- | ------------- | ------- | ----------- | -------- |
|
|
2306
|
-
| Bundle size | ~3KB | ~12KB | ~3KB | ~8KB | ~20KB |
|
|
2307
|
-
| Boilerplate | Minimal | Low | Minimal | Minimal | Medium |
|
|
2308
|
-
| TypeScript | First-class | First-class | Good | First-class | Good |
|
|
2309
|
-
| Async support | Built-in | RTK Query | Manual | Built-in | Built-in |
|
|
2310
|
-
| Fine-grained updates | Yes | No | Partial | Yes | Yes |
|
|
2311
|
-
| Suspense support | Native | No | No | Yes | Yes |
|
|
2312
|
-
| DevTools | Planned | Yes | Yes | Yes | Yes |
|
|
2313
|
-
| Learning curve | Low | Medium | Low | Low | Medium |
|
|
2314
|
-
|
|
2315
|
-
### When to Use atomirx
|
|
2316
|
-
|
|
2317
|
-
**Choose atomirx when you want:**
|
|
2318
|
-
|
|
2319
|
-
- Minimal API with maximum capability
|
|
2320
|
-
- First-class async support without additional packages
|
|
2321
|
-
- Fine-grained reactivity for optimal performance
|
|
2322
|
-
- React Suspense integration out of the box
|
|
2323
|
-
- Strong TypeScript inference
|
|
2324
|
-
|
|
2325
|
-
**Consider alternatives when you need:**
|
|
2326
|
-
|
|
2327
|
-
- Time-travel debugging (Redux Toolkit)
|
|
2328
|
-
- Established ecosystem with many plugins (Redux)
|
|
2329
|
-
- Server-side state management (TanStack Query)
|
|
2330
|
-
|
|
2331
|
-
## Resources & Learning
|
|
2332
|
-
|
|
2333
|
-
### Documentation
|
|
2334
|
-
|
|
2335
|
-
- [API Reference](#api-reference) - Complete API documentation
|
|
2336
|
-
- [Usage Guide](#usage-guide) - In-depth usage patterns
|
|
2337
|
-
- [React Integration](#react-integration) - React-specific features
|
|
2338
|
-
|
|
2339
|
-
### Examples
|
|
2340
|
-
|
|
2341
|
-
- [Counter](./examples/counter) - Basic counter example
|
|
2342
|
-
- [Todo App](./examples/todo) - Full todo application
|
|
2343
|
-
- [Async Data](./examples/async) - Async data fetching patterns
|
|
2344
|
-
- [Testing](./examples/testing) - Testing strategies with `define()`
|
|
315
|
+
This installs atomirx best practices, patterns, and API documentation to `.cursor/skills/atomirx/`, enabling your AI assistant to understand atomirx conventions.
|
|
2345
316
|
|
|
2346
|
-
|
|
317
|
+
**What's included:**
|
|
318
|
+
- Core patterns (atoms, derived, effects, pools)
|
|
319
|
+
- React integration (useSelector, rx, useAction, useStable)
|
|
320
|
+
- Service/Store architecture templates
|
|
321
|
+
- Testing patterns
|
|
322
|
+
- Error handling with `safe()`
|
|
2347
323
|
|
|
2348
|
-
|
|
2349
|
-
- [Discussions](https://github.com/linq2js/atomirx/discussions) - Questions and community support
|
|
324
|
+
---
|
|
2350
325
|
|
|
2351
|
-
##
|
|
326
|
+
## Links
|
|
2352
327
|
|
|
2353
|
-
|
|
328
|
+
- [GitHub](https://github.com/linq2js/atomirx) - Source code & issues
|
|
329
|
+
- [Changelog](https://github.com/linq2js/atomirx/releases) - Release notes
|
|
2354
330
|
|
|
2355
|
-
|
|
331
|
+
---
|
|
2356
332
|
|
|
2357
|
-
|
|
2358
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
2359
|
-
in the Software without restriction, including without limitation the rights
|
|
2360
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
2361
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
2362
|
-
furnished to do so, subject to the following conditions:
|
|
333
|
+
<div align="center">
|
|
2363
334
|
|
|
2364
|
-
|
|
2365
|
-
copies or substantial portions of the Software.
|
|
335
|
+
**MIT License**
|
|
2366
336
|
|
|
2367
|
-
|
|
2368
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
2369
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
2370
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
2371
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
2372
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
2373
|
-
SOFTWARE.
|
|
337
|
+
</div>
|