opencode-effect-enforcer 0.2.2 → 0.2.4
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 +38 -10
- package/docs/effect-4.0.0-rc.112.md +316 -0
- package/guidance/effect-first-development.md +30 -17
- package/guidance/progressive-disclosure-guidance.md +13 -0
- package/package.json +3 -2
- package/patterns/avoid-direct-tag-checks.md +8 -2
- package/patterns/avoid-react-hooks.md +18 -37
- package/patterns/effect-run-in-body.md +1 -1
- package/patterns/require-effect-concurrency.md +11 -0
- package/patterns/use-console-service.md +6 -1
- package/skills/effect-ai-language-model/SKILL.md +10 -16
- package/skills/effect-ai-prompt/SKILL.md +36 -2
- package/skills/effect-ai-provider/SKILL.md +13 -0
- package/skills/effect-ai-streaming/SKILL.md +81 -108
- package/skills/effect-ai-tool/SKILL.md +50 -87
- package/skills/effect-atom-rpc/SKILL.md +9 -2
- package/skills/effect-atom-state/SKILL.md +5 -0
- package/skills/effect-cache/SKILL.md +32 -0
- package/skills/effect-cli/SKILL.md +22 -3
- package/skills/effect-concurrency-testing/SKILL.md +7 -9
- package/skills/effect-domain-modeling/SKILL.md +208 -1169
- package/skills/effect-domain-predicates/SKILL.md +5 -6
- package/skills/effect-error-handling/SKILL.md +5 -4
- package/skills/effect-http-api/SKILL.md +12 -1
- package/skills/effect-http-client/SKILL.md +1 -1
- package/skills/effect-http-server/SKILL.md +14 -3
- package/skills/effect-layer-design/SKILL.md +22 -56
- package/skills/effect-mcp-server/SKILL.md +1 -1
- package/skills/effect-pattern-matching/SKILL.md +44 -11
- package/skills/effect-platform-abstraction/SKILL.md +1 -1
- package/skills/effect-platform-layers/SKILL.md +1 -1
- package/skills/effect-rpc-api/SKILL.md +8 -1
- package/skills/effect-rpc-client/SKILL.md +20 -6
- package/skills/effect-rpc-cluster/SKILL.md +44 -14
- package/skills/effect-rpc-server/SKILL.md +32 -5
- package/skills/effect-scheduling/SKILL.md +1 -1
- package/skills/effect-schema-composition/SKILL.md +69 -15
- package/skills/effect-schema-v4/SKILL.md +43 -1
- package/skills/effect-scope/SKILL.md +30 -0
- package/skills/effect-service-implementation/SKILL.md +10 -4
- package/skills/effect-socket/SKILL.md +5 -5
- package/skills/effect-sql/SKILL.md +22 -0
- package/skills/effect-stream/SKILL.md +32 -1
- package/skills/effect-testing/SKILL.md +39 -31
- package/skills/effect-workflow/SKILL.md +6 -0
- package/patterns/vm-in-wrong-file.md +0 -51
- package/skills/effect-react-vm/SKILL.md +0 -675
|
@@ -1,675 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: effect-react-vm
|
|
3
|
-
description: Implement the VM pattern using Effect and Effect-Atom for reactive, testable frontend state management. Use this skill when building React applications with View Models that bridge domain services and UI.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Effectful View Model Architecture Guide
|
|
7
|
-
|
|
8
|
-
## Effect Source Reference
|
|
9
|
-
|
|
10
|
-
The Effect v4 source is available at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`.
|
|
11
|
-
Browse and read files there directly to look up APIs, types, and implementations.
|
|
12
|
-
|
|
13
|
-
Reference this for:
|
|
14
|
-
|
|
15
|
-
- Atom reactivity: `packages/effect/src/unstable/reactivity/`
|
|
16
|
-
- Context source: `packages/effect/src/Context.ts`
|
|
17
|
-
- Layer source: `packages/effect/src/Layer.ts`
|
|
18
|
-
- Effect source: `packages/effect/src/`
|
|
19
|
-
|
|
20
|
-
## The Golden Rule: Zero UI Logic
|
|
21
|
-
|
|
22
|
-
**VMs take domain input → VMs produce UI-ready output → Components are pure renderers**
|
|
23
|
-
|
|
24
|
-
VM transforms domain to UI-ready:
|
|
25
|
-
|
|
26
|
-
- `User` entity → `displayName: "John D."`
|
|
27
|
-
- `timestamp: 1702425600` → `formattedDate: "Dec 13, 2024"`
|
|
28
|
-
- `balance: 1000000n` → `displayBalance: "$1,000,000"`
|
|
29
|
-
- `isActive && hasAccess` → `canEdit: true`
|
|
30
|
-
- `error.code` → `errorMessage: "Network failed"`
|
|
31
|
-
|
|
32
|
-
**Components must NEVER:** format strings/dates/numbers, compute derived values, contain business logic, transform entities
|
|
33
|
-
|
|
34
|
-
**Components ONLY:** subscribe via `useAtomValue`, invoke via `useAtomSet`, pattern match with `$match`, render UI-ready values
|
|
35
|
-
|
|
36
|
-
**Error handling:** Components CAN pattern match on error states (to render different UI per error type), but MUST render `error.message` as-is—VM is responsible for producing user-friendly messages
|
|
37
|
-
|
|
38
|
-
---
|
|
39
|
-
|
|
40
|
-
## File Structure
|
|
41
|
-
|
|
42
|
-
Every **parent component** needs a VM:
|
|
43
|
-
|
|
44
|
-
```
|
|
45
|
-
components/
|
|
46
|
-
Wallet/
|
|
47
|
-
Wallet.tsx # Component - pure renderer
|
|
48
|
-
Wallet.vm.ts # VM - interface, tag, default layer export
|
|
49
|
-
index.ts # Re-exports
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Child components used for UI composition receive VM as props—only parent components define their own VM.
|
|
53
|
-
|
|
54
|
-
---
|
|
55
|
-
|
|
56
|
-
## VMs vs Regular Layers
|
|
57
|
-
|
|
58
|
-
**VMs are strictly UI constructs.** A VM only exists if a component for that exact VM exists.
|
|
59
|
-
|
|
60
|
-
| Pattern | When to Use | Location |
|
|
61
|
-
| ----------------- | ----------------------------------- | ------------------------------------------ |
|
|
62
|
-
| **VM** | Layer serves a React component | `components/X/X.vm.ts` paired with `X.tsx` |
|
|
63
|
-
| **Service Layer** | Non-UI logic, shared business rules | `services/`, `lib/`, etc. |
|
|
64
|
-
|
|
65
|
-
```typescript
|
|
66
|
-
// ❌ WRONG - No component uses this, not a VM
|
|
67
|
-
// components/Analytics/Analytics.vm.ts (but no Analytics.tsx!)
|
|
68
|
-
|
|
69
|
-
// ✅ CORRECT - Just a service layer
|
|
70
|
-
// services/Analytics.ts
|
|
71
|
-
export class AnalyticsService extends Context.Service<
|
|
72
|
-
AnalyticsService,
|
|
73
|
-
{ track: (event: string) => Effect.Effect<void> }
|
|
74
|
-
>()('AnalyticsService') {}
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
**When VMs share logic**: Use standard Effect layer composition. Shared logic lives in service layers, VMs compose over them:
|
|
78
|
-
|
|
79
|
-
```typescript
|
|
80
|
-
import { Context, Effect, Layer } from 'effect';
|
|
81
|
-
import { AtomRegistry } from 'effect/unstable/reactivity';
|
|
82
|
-
interface Consent {
|
|
83
|
-
id: string;
|
|
84
|
-
}
|
|
85
|
-
declare var ConsentListVM: Context.Service<ConsentListVM, ConsentListVM>;
|
|
86
|
-
interface ConsentListVM {}
|
|
87
|
-
|
|
88
|
-
// services/ConsentService.ts - shared business logic
|
|
89
|
-
export class ConsentService extends Context.Service<
|
|
90
|
-
ConsentService,
|
|
91
|
-
{ getConsents: Effect.Effect<Consent[]> }
|
|
92
|
-
>()('ConsentService') {}
|
|
93
|
-
|
|
94
|
-
// components/ConsentList/ConsentList.vm.ts - UI-specific, uses service
|
|
95
|
-
const layer = Layer.effect(
|
|
96
|
-
ConsentListVM,
|
|
97
|
-
Effect.gen(function* () {
|
|
98
|
-
const consentService = yield* ConsentService; // Compose over service
|
|
99
|
-
const registry = yield* AtomRegistry.AtomRegistry;
|
|
100
|
-
// ... VM-specific UI state
|
|
101
|
-
})
|
|
102
|
-
);
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
---
|
|
106
|
-
|
|
107
|
-
## Architecture Flow
|
|
108
|
-
|
|
109
|
-
- Component calls `useVM(tag, layer)` → VMRuntime lazily builds VM via `Layer.buildWithMemoMap` → VM yields services from infrastructure layers
|
|
110
|
-
- VMRuntime provides render-stable scope for all VMs
|
|
111
|
-
- User action → VM action (updates atom via registry) → atom notifies → `useAtomValue` re-renders
|
|
112
|
-
|
|
113
|
-
---
|
|
114
|
-
|
|
115
|
-
## VM File Pattern
|
|
116
|
-
|
|
117
|
-
Each VM file contains: interface, tag, and default `{ tag, layer }` export.
|
|
118
|
-
|
|
119
|
-
```typescript
|
|
120
|
-
// components/Wallet/Wallet.vm.ts
|
|
121
|
-
import * as Atom from 'effect/unstable/reactivity/Atom';
|
|
122
|
-
import { AtomRegistry } from 'effect/unstable/reactivity';
|
|
123
|
-
import { Context, Layer, Effect, pipe, Data } from 'effect';
|
|
124
|
-
|
|
125
|
-
// State machine
|
|
126
|
-
export type WalletState = Data.TaggedEnum<{
|
|
127
|
-
Disconnected: {};
|
|
128
|
-
Connecting: {};
|
|
129
|
-
Connected: { displayAddress: string; fullAddress: string };
|
|
130
|
-
}>;
|
|
131
|
-
export const WalletState = Data.taggedEnum<WalletState>();
|
|
132
|
-
|
|
133
|
-
// 1. Interface - atoms use camelCase with $ suffix
|
|
134
|
-
export interface WalletVM {
|
|
135
|
-
readonly state$: Atom.Atom<WalletState>;
|
|
136
|
-
readonly isConnected$: Atom.Atom<boolean>; // Derived, UI-ready
|
|
137
|
-
readonly connect: () => void; // Actions return void
|
|
138
|
-
readonly disconnect: () => void;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// 2. Tag
|
|
142
|
-
export const WalletVM = Context.Service<WalletVM>('WalletVM');
|
|
143
|
-
|
|
144
|
-
// 3. Layer - atoms ONLY defined inside the layer
|
|
145
|
-
// VMRuntime provides scope, so Layer.effect is the default
|
|
146
|
-
const layer = Layer.effect(
|
|
147
|
-
WalletVM,
|
|
148
|
-
Effect.gen(function* () {
|
|
149
|
-
const registry = yield* AtomRegistry.AtomRegistry;
|
|
150
|
-
const walletService = yield* WalletService;
|
|
151
|
-
|
|
152
|
-
// Atoms defined here, inside the layer
|
|
153
|
-
const state$ = Atom.make<WalletState>(WalletState.Disconnected());
|
|
154
|
-
const isConnected$ = pipe(
|
|
155
|
-
state$,
|
|
156
|
-
Atom.map(WalletState.$is('Connected'))
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
const connect = () => {
|
|
160
|
-
registry.set(state$, WalletState.Connecting());
|
|
161
|
-
Effect.runPromise(
|
|
162
|
-
walletService.connect.pipe(
|
|
163
|
-
Effect.match({
|
|
164
|
-
onFailure: () =>
|
|
165
|
-
registry.set(state$, WalletState.Disconnected()),
|
|
166
|
-
onSuccess: (addr) =>
|
|
167
|
-
registry.set(
|
|
168
|
-
state$,
|
|
169
|
-
WalletState.Connected({
|
|
170
|
-
displayAddress: `${addr.slice(0, 6)}...${addr.slice(-4)}`,
|
|
171
|
-
fullAddress: addr
|
|
172
|
-
})
|
|
173
|
-
)
|
|
174
|
-
})
|
|
175
|
-
)
|
|
176
|
-
);
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
const disconnect = () => {
|
|
180
|
-
registry.set(state$, WalletState.Disconnected());
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
return { state$, isConnected$, connect, disconnect };
|
|
184
|
-
})
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
// 4. Default export
|
|
188
|
-
export default { tag: WalletVM, layer };
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
---
|
|
192
|
-
|
|
193
|
-
## Component Pattern
|
|
194
|
-
|
|
195
|
-
```tsx
|
|
196
|
-
// components/Wallet/Wallet.tsx
|
|
197
|
-
'use client';
|
|
198
|
-
import { useVM } from '@/lib/VMRuntime';
|
|
199
|
-
import { useAtomValue } from '@effect/atom-react';
|
|
200
|
-
import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
|
|
201
|
-
import WalletVM, {
|
|
202
|
-
WalletState,
|
|
203
|
-
type WalletVM as WalletVMType
|
|
204
|
-
} from './Wallet.vm';
|
|
205
|
-
|
|
206
|
-
// Child components receive VM as prop - no own VM needed
|
|
207
|
-
function WalletStatus({ vm }: { vm: WalletVMType }) {
|
|
208
|
-
const state = useAtomValue(vm.state$);
|
|
209
|
-
|
|
210
|
-
return WalletState.$match(state, {
|
|
211
|
-
Disconnected: () => <span>Not connected</span>,
|
|
212
|
-
Connecting: () => <Spinner />,
|
|
213
|
-
Connected: ({ displayAddress }) => <span>{displayAddress}</span>
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function WalletActions({ vm }: { vm: WalletVMType }) {
|
|
218
|
-
const isConnected = useAtomValue(vm.isConnected$);
|
|
219
|
-
|
|
220
|
-
return isConnected ? (
|
|
221
|
-
<button onClick={vm.disconnect}>Disconnect</button>
|
|
222
|
-
) : (
|
|
223
|
-
<button onClick={vm.connect}>Connect</button>
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Parent component owns VM
|
|
228
|
-
export default function Wallet() {
|
|
229
|
-
const vmResult = useVM(WalletVM.tag, WalletVM.layer);
|
|
230
|
-
|
|
231
|
-
return AsyncResult.match(vmResult, {
|
|
232
|
-
onInitial: () => <Spinner />,
|
|
233
|
-
onSuccess: ({ value: vm }) => (
|
|
234
|
-
<div className="wallet">
|
|
235
|
-
<WalletStatus vm={vm} />
|
|
236
|
-
<WalletActions vm={vm} />
|
|
237
|
-
</div>
|
|
238
|
-
),
|
|
239
|
-
onFailure: ({ cause }) => <Alert>{String(cause)}</Alert>
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
---
|
|
245
|
-
|
|
246
|
-
## Core Pattern: Atom.fn for Async Actions
|
|
247
|
-
|
|
248
|
-
**Key insight**: Use `Atom.fn` with `Effect.fnUntraced` for effect-based actions. This gives you:
|
|
249
|
-
|
|
250
|
-
1. Automatic `waiting` flag for loading state
|
|
251
|
-
2. `AsyncResult<Success, Error>` with `Initial`, `Success`, and `Failure` variants plus a top-level `waiting` overlay
|
|
252
|
-
3. No manual state management or void wrappers
|
|
253
|
-
|
|
254
|
-
```tsx
|
|
255
|
-
import * as Atom from 'effect/unstable/reactivity/Atom';
|
|
256
|
-
import { useAtomValue, useAtomSet } from '@effect/atom-react';
|
|
257
|
-
import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
|
|
258
|
-
import { Effect, Exit } from 'effect';
|
|
259
|
-
|
|
260
|
-
// Define action with Atom.fn + Effect.fnUntraced
|
|
261
|
-
const refreshAtom = Atom.fn(
|
|
262
|
-
Effect.fnUntraced(function* () {
|
|
263
|
-
const consents = yield* consentService.getOwnConsents;
|
|
264
|
-
return consents;
|
|
265
|
-
})
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
// In component - useAtom for result and trigger
|
|
269
|
-
function ConsentList() {
|
|
270
|
-
const [result, refresh] = useAtom(refreshAtom);
|
|
271
|
-
|
|
272
|
-
// result.waiting is true while the effect runs
|
|
273
|
-
const isLoading = result.waiting;
|
|
274
|
-
|
|
275
|
-
return (
|
|
276
|
-
<div>
|
|
277
|
-
<button onClick={() => refresh()} disabled={isLoading}>
|
|
278
|
-
{isLoading ? 'Loading...' : 'Refresh'}
|
|
279
|
-
</button>
|
|
280
|
-
{AsyncResult.matchWithWaiting(result, {
|
|
281
|
-
onWaiting: () => <Loading />,
|
|
282
|
-
onSuccess: ({ value }) => <List items={value} />,
|
|
283
|
-
onError: (error) => <Error message={String(error)} />,
|
|
284
|
-
onDefect: (defect) => <Error message={String(defect)} />
|
|
285
|
-
})}
|
|
286
|
-
</div>
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
**With services using Atom.runtime:**
|
|
292
|
-
|
|
293
|
-
```tsx
|
|
294
|
-
class ConsentService extends Context.Service<ConsentService>()(
|
|
295
|
-
'ConsentService',
|
|
296
|
-
{
|
|
297
|
-
make: Effect.gen(function* () {
|
|
298
|
-
const getAll = Effect.succeed([{ id: '1', name: 'Terms' }]);
|
|
299
|
-
return { getAll } as const;
|
|
300
|
-
})
|
|
301
|
-
}
|
|
302
|
-
) {}
|
|
303
|
-
|
|
304
|
-
const runtimeAtom = Atom.runtime(ConsentService.layer);
|
|
305
|
-
|
|
306
|
-
const refreshAtom = runtimeAtom.fn(
|
|
307
|
-
Effect.fnUntraced(function* () {
|
|
308
|
-
const service = yield* ConsentService;
|
|
309
|
-
return yield* service.getAll;
|
|
310
|
-
})
|
|
311
|
-
);
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
**With promiseExit for async handlers:**
|
|
315
|
-
|
|
316
|
-
```tsx
|
|
317
|
-
function CreateUser() {
|
|
318
|
-
// mode: "promiseExit" returns Promise<Exit<...>> for await
|
|
319
|
-
const createUser = useAtomSet(createUserAtom, { mode: 'promiseExit' });
|
|
320
|
-
|
|
321
|
-
return (
|
|
322
|
-
<button
|
|
323
|
-
onClick={async () => {
|
|
324
|
-
const exit = await createUser('John');
|
|
325
|
-
if (Exit.isSuccess(exit)) {
|
|
326
|
-
// exit.value contains the created user
|
|
327
|
-
}
|
|
328
|
-
}}
|
|
329
|
-
>
|
|
330
|
-
Create
|
|
331
|
-
</button>
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
**Anti-pattern: Manual void wrappers**
|
|
337
|
-
|
|
338
|
-
```typescript
|
|
339
|
-
// ❌ DON'T - manual state management loses waiting control
|
|
340
|
-
const loading$ = Atom.make(false);
|
|
341
|
-
const data$ = Atom.make<Data | null>(null);
|
|
342
|
-
|
|
343
|
-
const refresh = (): void => {
|
|
344
|
-
registry.set(loading$, true);
|
|
345
|
-
Effect.runPromise(fetchData).then((data) => {
|
|
346
|
-
registry.set(data$, data);
|
|
347
|
-
registry.set(loading$, false);
|
|
348
|
-
});
|
|
349
|
-
};
|
|
350
|
-
|
|
351
|
-
// ✅ DO - Atom.fn handles everything
|
|
352
|
-
const refreshAtom = Atom.fn(
|
|
353
|
-
Effect.fnUntraced(function* () {
|
|
354
|
-
return yield* fetchData;
|
|
355
|
-
})
|
|
356
|
-
);
|
|
357
|
-
// result.waiting, AsyncResult.matchWithWaiting - all built-in
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
---
|
|
361
|
-
|
|
362
|
-
## Building Blocks
|
|
363
|
-
|
|
364
|
-
### Atoms & Registry
|
|
365
|
-
|
|
366
|
-
Atoms are ONLY defined inside VM layers:
|
|
367
|
-
|
|
368
|
-
```typescript
|
|
369
|
-
// Inside Layer.effect
|
|
370
|
-
const registry = yield* AtomRegistry.AtomRegistry;
|
|
371
|
-
|
|
372
|
-
// Writable atom - camelCase with $ suffix
|
|
373
|
-
const count$ = Atom.make(0);
|
|
374
|
-
|
|
375
|
-
// Derived atom (read-only)
|
|
376
|
-
const doubled$ = pipe(
|
|
377
|
-
count$,
|
|
378
|
-
Atom.map((n) => n * 2)
|
|
379
|
-
);
|
|
380
|
-
|
|
381
|
-
// Read/write via registry
|
|
382
|
-
registry.get(count$); // read
|
|
383
|
-
registry.set(count$, 42); // write
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
For UI-ready object values rebuilt from multiple atoms, use `Atom.withEquality` when semantic equality should suppress a React notification. The comparator must compare the complete rendered meaning of the value; omitting a rendered field can leave the UI stale.
|
|
387
|
-
|
|
388
|
-
```typescript
|
|
389
|
-
declare const HeaderViewEquivalence: (left: HeaderView, right: HeaderView) => boolean;
|
|
390
|
-
|
|
391
|
-
const header$ = Atom.make((get) => {
|
|
392
|
-
const state = get(state$);
|
|
393
|
-
return toHeaderView(state);
|
|
394
|
-
}).pipe(Atom.withEquality(HeaderViewEquivalence));
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
### Data.TaggedEnum - State Machines
|
|
398
|
-
|
|
399
|
-
```tsx
|
|
400
|
-
export type WalletState = Data.TaggedEnum<{
|
|
401
|
-
Disconnected: {};
|
|
402
|
-
Connecting: {};
|
|
403
|
-
Connected: { displayAddress: string; fullAddress: string };
|
|
404
|
-
}>;
|
|
405
|
-
export const WalletState = Data.taggedEnum<WalletState>();
|
|
406
|
-
|
|
407
|
-
// Pattern match in UI
|
|
408
|
-
WalletState.$match(state, {
|
|
409
|
-
Disconnected: () => <ConnectButton />,
|
|
410
|
-
Connecting: () => <Spinner />,
|
|
411
|
-
Connected: ({ displayAddress }) => <span>{displayAddress}</span>
|
|
412
|
-
});
|
|
413
|
-
```
|
|
414
|
-
|
|
415
|
-
### VMs with Lists (Atom.family)
|
|
416
|
-
|
|
417
|
-
```typescript
|
|
418
|
-
const makeConsentItemVM = Atom.family((consent: Consent): ConsentItemVM => {
|
|
419
|
-
const status$ = pipe(
|
|
420
|
-
consentsState$,
|
|
421
|
-
Atom.map((either) =>
|
|
422
|
-
Either.match(either, {
|
|
423
|
-
onLeft: () => ConsentStatus.Active(),
|
|
424
|
-
onRight: (consents) => {
|
|
425
|
-
const c = consents.find(
|
|
426
|
-
(x) => x.consentId === consent.consentId
|
|
427
|
-
);
|
|
428
|
-
return c?.isRevoked
|
|
429
|
-
? ConsentStatus.Revoked()
|
|
430
|
-
: ConsentStatus.Active();
|
|
431
|
-
}
|
|
432
|
-
})
|
|
433
|
-
)
|
|
434
|
-
);
|
|
435
|
-
|
|
436
|
-
// Close over consent.consentId - UI never sees it
|
|
437
|
-
const revoke = () => {
|
|
438
|
-
Effect.gen(function* () {
|
|
439
|
-
yield* consentService.revokeById(consent.consentId);
|
|
440
|
-
yield* refresh();
|
|
441
|
-
}).pipe(Effect.runFork);
|
|
442
|
-
};
|
|
443
|
-
|
|
444
|
-
return { key: consent.consentId, status$, revoke };
|
|
445
|
-
});
|
|
446
|
-
```
|
|
447
|
-
|
|
448
|
-
### Event Listeners → Atom with Finalizer
|
|
449
|
-
|
|
450
|
-
Instead of `useEffect` for event listeners, use `Atom.make` with `get.addFinalizer`:
|
|
451
|
-
|
|
452
|
-
```typescript
|
|
453
|
-
// Window scroll position - auto-cleanup when atom is no longer used
|
|
454
|
-
const scrollY$ = Atom.make((get) => {
|
|
455
|
-
const onScroll = () => get.setSelf(window.scrollY);
|
|
456
|
-
window.addEventListener('scroll', onScroll);
|
|
457
|
-
get.addFinalizer(() => window.removeEventListener('scroll', onScroll));
|
|
458
|
-
return window.scrollY;
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
// Resize observer
|
|
462
|
-
const windowSize$ = Atom.make((get) => {
|
|
463
|
-
const update = () =>
|
|
464
|
-
get.setSelf({ width: window.innerWidth, height: window.innerHeight });
|
|
465
|
-
window.addEventListener('resize', update);
|
|
466
|
-
get.addFinalizer(() => window.removeEventListener('resize', update));
|
|
467
|
-
return { width: window.innerWidth, height: window.innerHeight };
|
|
468
|
-
});
|
|
469
|
-
```
|
|
470
|
-
|
|
471
|
-
### URL Search Params → Atom.searchParam
|
|
472
|
-
|
|
473
|
-
Instead of `useEffect` + `useSearchParams`, use `Atom.searchParam`:
|
|
474
|
-
|
|
475
|
-
```typescript
|
|
476
|
-
// Simple string param
|
|
477
|
-
const filter$ = Atom.searchParam('filter'); // Atom.Writable<string>
|
|
478
|
-
|
|
479
|
-
// With schema parsing
|
|
480
|
-
const page$ = Atom.searchParam('page', {
|
|
481
|
-
schema: Schema.NumberFromString
|
|
482
|
-
}); // Atom.Writable<Option<number>>
|
|
483
|
-
|
|
484
|
-
// Multiple params for a search form
|
|
485
|
-
const search$ = Atom.searchParam('q');
|
|
486
|
-
const sort$ = Atom.searchParam('sort');
|
|
487
|
-
const limit$ = Atom.searchParam('limit', { schema: Schema.NumberFromString });
|
|
488
|
-
```
|
|
489
|
-
|
|
490
|
-
---
|
|
491
|
-
|
|
492
|
-
## VMRuntime Hook
|
|
493
|
-
|
|
494
|
-
```typescript
|
|
495
|
-
// lib/VMRuntime.ts
|
|
496
|
-
const memoMap = Layer.makeMemoMap.pipe(Effect.runSync);
|
|
497
|
-
|
|
498
|
-
const vmAtom = Atom.family(<Id, Value, E>(key: VmKey<Id, Value, E>) =>
|
|
499
|
-
Atom.make(
|
|
500
|
-
Effect.gen(function* () {
|
|
501
|
-
const scope = yield* Scope.Scope;
|
|
502
|
-
const ctx = yield* Layer.buildWithMemoMap(
|
|
503
|
-
key.layer,
|
|
504
|
-
memoMap,
|
|
505
|
-
scope
|
|
506
|
-
);
|
|
507
|
-
return Context.get(ctx, key.tag);
|
|
508
|
-
})
|
|
509
|
-
)
|
|
510
|
-
);
|
|
511
|
-
|
|
512
|
-
export const useVM = <Id, Value, E>(
|
|
513
|
-
tag: Context.Service<Id, Value>,
|
|
514
|
-
layer: Layer.Layer<Id, E, Scope.Scope | AtomRegistry.AtomRegistry>
|
|
515
|
-
): AsyncResult.AsyncResult<Value, E> =>
|
|
516
|
-
useAtomValue(vmAtom(makeVmKey(tag, layer)));
|
|
517
|
-
```
|
|
518
|
-
|
|
519
|
-
---
|
|
520
|
-
|
|
521
|
-
## React Integration
|
|
522
|
-
|
|
523
|
-
### Provider Setup
|
|
524
|
-
|
|
525
|
-
```tsx
|
|
526
|
-
// app/providers.tsx
|
|
527
|
-
import { RegistryProvider } from '@effect/atom-react';
|
|
528
|
-
|
|
529
|
-
export function Providers({ children }: { children: React.ReactNode }) {
|
|
530
|
-
return <RegistryProvider>{children}</RegistryProvider>;
|
|
531
|
-
}
|
|
532
|
-
```
|
|
533
|
-
|
|
534
|
-
### Hooks Reference
|
|
535
|
-
|
|
536
|
-
| Hook | Purpose |
|
|
537
|
-
| ---------------------------------------------- | ------------------------------------------------------------------ |
|
|
538
|
-
| `useAtomValue(atom$)` | Subscribe to value |
|
|
539
|
-
| `useAtomSet(atom$)` | Get setter function and mount writable atom |
|
|
540
|
-
| `useAtom(atom$)` | Get `[value, setter]` |
|
|
541
|
-
| `useAtomMount(atom$)` | Mount side-effect atoms without reading |
|
|
542
|
-
| `useAtomRefresh(atom$)` | Mount and get a refresh callback |
|
|
543
|
-
| `useAtomSuspense(asyncResultAtom$, options?)` | Read `AsyncResult` atoms through React Suspense |
|
|
544
|
-
| `useAtomInitialValues(values)` | Seed initial atom values in the current registry |
|
|
545
|
-
| `useAtomSubscribe(atom$, callback, options?)` | Subscribe to changes without rendering from the atom |
|
|
546
|
-
| `useAtomRef(ref)` | Subscribe to an `AtomRef` value directly |
|
|
547
|
-
| `useAtomRefProp(ref, key)` | Memoize an `AtomRef` for an object property |
|
|
548
|
-
| `useAtomRefPropValue(ref, key)` | Subscribe to one property value from an object-shaped `AtomRef` |
|
|
549
|
-
|
|
550
|
-
---
|
|
551
|
-
|
|
552
|
-
## Testing VMs
|
|
553
|
-
|
|
554
|
-
```typescript
|
|
555
|
-
describe('WalletVM', () => {
|
|
556
|
-
const WalletServiceMock = Layer.succeed(
|
|
557
|
-
WalletService,
|
|
558
|
-
WalletService.of({
|
|
559
|
-
connect: Effect.succeed('0x1234...'),
|
|
560
|
-
disconnect: Effect.succeed(undefined)
|
|
561
|
-
})
|
|
562
|
-
);
|
|
563
|
-
|
|
564
|
-
const makeVM = () => {
|
|
565
|
-
const r = AtomRegistry.make();
|
|
566
|
-
const vm = Layer.build(WalletVM.layer).pipe(
|
|
567
|
-
Effect.map((ctx) => Context.get(ctx, WalletVM.tag)),
|
|
568
|
-
Effect.scoped,
|
|
569
|
-
Effect.provideService(AtomRegistry.AtomRegistry, r),
|
|
570
|
-
Effect.provide(WalletServiceMock),
|
|
571
|
-
Effect.runSync
|
|
572
|
-
);
|
|
573
|
-
return { r, vm };
|
|
574
|
-
};
|
|
575
|
-
|
|
576
|
-
it('should start disconnected', () => {
|
|
577
|
-
const { r, vm } = makeVM();
|
|
578
|
-
expect(WalletState.$is('Disconnected')(r.get(vm.state$))).toBe(true);
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
it('should connect wallet', async () => {
|
|
582
|
-
const { r, vm } = makeVM();
|
|
583
|
-
vm.connect();
|
|
584
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
585
|
-
expect(WalletState.$is('Connected')(r.get(vm.state$))).toBe(true);
|
|
586
|
-
});
|
|
587
|
-
});
|
|
588
|
-
```
|
|
589
|
-
|
|
590
|
-
---
|
|
591
|
-
|
|
592
|
-
## Best Practices
|
|
593
|
-
|
|
594
|
-
**Core Pattern**
|
|
595
|
-
|
|
596
|
-
- Use `Atom.fn()` for async actions—gives you `AtomResultFn` with automatic `waiting` flag
|
|
597
|
-
- Use `useAtom(action$)` to get `[result, trigger]` tuple
|
|
598
|
-
- `AsyncResult.matchWithWaiting` for rendering async states (onWaiting/onSuccess/onError/onDefect)
|
|
599
|
-
- `AsyncResult.match` for one-time builds like VM initialization (onInitial/onSuccess/onFailure)
|
|
600
|
-
- Never manually wrap Effects in void functions—you lose `waiting` control
|
|
601
|
-
|
|
602
|
-
**Naming & Structure**
|
|
603
|
-
|
|
604
|
-
- Atoms use `camelCase$` suffix
|
|
605
|
-
- Every parent component: `Component.tsx` + `Component.vm.ts`
|
|
606
|
-
- Child components receive VM as prop (no own VM)
|
|
607
|
-
- VM file exports: interface, tag, default `{ tag, layer }`
|
|
608
|
-
|
|
609
|
-
**Interface Design**
|
|
610
|
-
|
|
611
|
-
- ALL formatting happens in VM—components receive ready-to-render strings
|
|
612
|
-
- Use `key` for React, close over IDs in callbacks
|
|
613
|
-
|
|
614
|
-
### UI-Ready Output Examples
|
|
615
|
-
|
|
616
|
-
```tsx
|
|
617
|
-
// WRONG - Logic in component
|
|
618
|
-
function UserCard({ vm }: { vm: UserVM }) {
|
|
619
|
-
const user = useAtomValue(vm.user$);
|
|
620
|
-
const balance = useAtomValue(vm.balance$);
|
|
621
|
-
|
|
622
|
-
// NO! Formatting in component
|
|
623
|
-
const displayName = `${user.firstName} ${user.lastName.charAt(0)}.`;
|
|
624
|
-
const formattedBalance = new Intl.NumberFormat('en-US', {
|
|
625
|
-
style: 'currency',
|
|
626
|
-
currency: 'USD'
|
|
627
|
-
}).format(balance / 100);
|
|
628
|
-
const isVip =
|
|
629
|
-
balance > 10000 && user.memberSince < Date.now() - 31536000000;
|
|
630
|
-
|
|
631
|
-
return (
|
|
632
|
-
<div>
|
|
633
|
-
<h2>{displayName}</h2>
|
|
634
|
-
<span>{formattedBalance}</span>
|
|
635
|
-
{isVip && <VipBadge />} {/* NO! Conditional logic */}
|
|
636
|
-
</div>
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// CORRECT - VM produces UI-ready values
|
|
641
|
-
interface UserVM {
|
|
642
|
-
readonly displayName$: Atom.Atom<string>; // "John D."
|
|
643
|
-
readonly formattedBalance$: Atom.Atom<string>; // "$1,234.56"
|
|
644
|
-
readonly showVipBadge$: Atom.Atom<boolean>; // true/false
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
function UserCard({ vm }: { vm: UserVM }) {
|
|
648
|
-
const displayName = useAtomValue(vm.displayName$);
|
|
649
|
-
const formattedBalance = useAtomValue(vm.formattedBalance$);
|
|
650
|
-
const showVipBadge = useAtomValue(vm.showVipBadge$);
|
|
651
|
-
|
|
652
|
-
return (
|
|
653
|
-
<div>
|
|
654
|
-
<h2>{displayName}</h2>
|
|
655
|
-
<span>{formattedBalance}</span>
|
|
656
|
-
{showVipBadge && <VipBadge />} {/* OK - just reading a boolean */}
|
|
657
|
-
</div>
|
|
658
|
-
);
|
|
659
|
-
}
|
|
660
|
-
```
|
|
661
|
-
|
|
662
|
-
**Implementation**
|
|
663
|
-
|
|
664
|
-
- Atoms ONLY defined inside VM layers
|
|
665
|
-
- `Layer.effect` is the default (VMRuntime provides scope)
|
|
666
|
-
- Use `Atom.family` for list item sub-VMs
|
|
667
|
-
- Use `Effect.forkScoped` for background tasks
|
|
668
|
-
- Handle all errors in actions (update atom on failure)
|
|
669
|
-
- Use `Atom.withEquality` for rebuilt UI-ready objects only when a complete semantic equivalence is available
|
|
670
|
-
|
|
671
|
-
**Testing**
|
|
672
|
-
|
|
673
|
-
- Test VMs without UI using registry directly
|
|
674
|
-
- Create fresh VM per test
|
|
675
|
-
- Mock services with `Layer.succeed`
|