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.
Files changed (47) hide show
  1. package/README.md +38 -10
  2. package/docs/effect-4.0.0-rc.112.md +316 -0
  3. package/guidance/effect-first-development.md +30 -17
  4. package/guidance/progressive-disclosure-guidance.md +13 -0
  5. package/package.json +3 -2
  6. package/patterns/avoid-direct-tag-checks.md +8 -2
  7. package/patterns/avoid-react-hooks.md +18 -37
  8. package/patterns/effect-run-in-body.md +1 -1
  9. package/patterns/require-effect-concurrency.md +11 -0
  10. package/patterns/use-console-service.md +6 -1
  11. package/skills/effect-ai-language-model/SKILL.md +10 -16
  12. package/skills/effect-ai-prompt/SKILL.md +36 -2
  13. package/skills/effect-ai-provider/SKILL.md +13 -0
  14. package/skills/effect-ai-streaming/SKILL.md +81 -108
  15. package/skills/effect-ai-tool/SKILL.md +50 -87
  16. package/skills/effect-atom-rpc/SKILL.md +9 -2
  17. package/skills/effect-atom-state/SKILL.md +5 -0
  18. package/skills/effect-cache/SKILL.md +32 -0
  19. package/skills/effect-cli/SKILL.md +22 -3
  20. package/skills/effect-concurrency-testing/SKILL.md +7 -9
  21. package/skills/effect-domain-modeling/SKILL.md +208 -1169
  22. package/skills/effect-domain-predicates/SKILL.md +5 -6
  23. package/skills/effect-error-handling/SKILL.md +5 -4
  24. package/skills/effect-http-api/SKILL.md +12 -1
  25. package/skills/effect-http-client/SKILL.md +1 -1
  26. package/skills/effect-http-server/SKILL.md +14 -3
  27. package/skills/effect-layer-design/SKILL.md +22 -56
  28. package/skills/effect-mcp-server/SKILL.md +1 -1
  29. package/skills/effect-pattern-matching/SKILL.md +44 -11
  30. package/skills/effect-platform-abstraction/SKILL.md +1 -1
  31. package/skills/effect-platform-layers/SKILL.md +1 -1
  32. package/skills/effect-rpc-api/SKILL.md +8 -1
  33. package/skills/effect-rpc-client/SKILL.md +20 -6
  34. package/skills/effect-rpc-cluster/SKILL.md +44 -14
  35. package/skills/effect-rpc-server/SKILL.md +32 -5
  36. package/skills/effect-scheduling/SKILL.md +1 -1
  37. package/skills/effect-schema-composition/SKILL.md +69 -15
  38. package/skills/effect-schema-v4/SKILL.md +43 -1
  39. package/skills/effect-scope/SKILL.md +30 -0
  40. package/skills/effect-service-implementation/SKILL.md +10 -4
  41. package/skills/effect-socket/SKILL.md +5 -5
  42. package/skills/effect-sql/SKILL.md +22 -0
  43. package/skills/effect-stream/SKILL.md +32 -1
  44. package/skills/effect-testing/SKILL.md +39 -31
  45. package/skills/effect-workflow/SKILL.md +6 -0
  46. package/patterns/vm-in-wrong-file.md +0 -51
  47. 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`