skillby kriegcloud
react-vm
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.
Installs: 0
Used in: 1 repos
Updated: 1w ago
$
npx ai-builder add skill kriegcloud/react-vmInstalls to .claude/skills/react-vm/
# Effectful View Model Architecture Guide
## The Golden Rule: Zero UI Logic
**VMs take domain input → VMs produce UI-ready output → Components are pure renderers**
VM transforms domain to UI-ready:
- `User` entity → `displayName: "John D."`
- `timestamp: 1702425600` → `formattedDate: "Dec 13, 2024"`
- `balance: 1000000n` → `displayBalance: "$1,000,000"`
- `isActive && hasAccess` → `canEdit: true`
- `error.code` → `errorMessage: "Network failed"`
**Components must NEVER:** format strings/dates/numbers, compute derived values, contain business logic, transform entities
**Components ONLY:** subscribe via `useAtomValue`, invoke via `useAtomSet`, pattern match with `$match`, render UI-ready values
**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
---
## File Structure
Every **parent component** needs a VM:
```
components/
Wallet/
Wallet.tsx # Component - pure renderer
Wallet.vm.ts # VM - interface, tag, default layer export
index.ts # Re-exports
```
Child components used for UI composition receive VM as props—only parent components define their own VM.
---
## VMs vs Regular Layers
**VMs are strictly UI constructs.** A VM only exists if a component for that exact VM exists.
| Pattern | When to Use | Location |
|---------|-------------|----------|
| **VM** | Layer serves a React component | `components/X/X.vm.ts` paired with `X.tsx` |
| **Service Layer** | Non-UI logic, shared business rules | `services/`, `lib/`, etc. |
```typescript
// ❌ WRONG - No component uses this, not a VM
// components/Analytics/Analytics.vm.ts (but no Analytics.tsx!)
// ✅ CORRECT - Just a service layer
// services/Analytics.ts
export class AnalyticsService extends Context.Tag("AnalyticsService")<
AnalyticsService,
{ track: (event: string) => Effect.Effect<void> }
>() {}
```
**When VMs share logic**: Use standard Effect layer composition. Shared logic lives in service layers, VMs compose over them:
```typescript
import { Context, Effect, Layer } from "effect"
import { AtomRegistry } from "@effect-atom/atom/Registry"
interface Consent { id: string }
declare var ConsentListVM: Context.Tag<ConsentListVM, ConsentListVM>
interface ConsentListVM {}
// services/ConsentService.ts - shared business logic
export class ConsentService extends Context.Tag("ConsentService")<
ConsentService,
{ getConsents: Effect.Effect<Consent[]> }
>() {}
// components/ConsentList/ConsentList.vm.ts - UI-specific, uses service
const layer = Layer.effect(
ConsentListVM,
Effect.gen(function* () {
const consentService = yield* ConsentService // Compose over service
const registry = yield* AtomRegistry
// ... VM-specific UI state
})
)
```
---
## Architecture Flow
- Component calls `useVM(tag, layer)` → VMRuntime lazily builds VM via `Layer.buildWithMemoMap` → VM yields services from infrastructure layers
- VMRuntime provides render-stable scope for all VMs
- User action → VM action (updates atom via registry) → atom notifies → `useAtomValue` re-renders
---
## VM File Pattern
Each VM file contains: interface, tag, and default `{ tag, layer }` export.
```typescript
// components/Wallet/Wallet.vm.ts
import * as Atom from "@effect-atom/atom/Atom"
import { AtomRegistry } from "@effect-atom/atom/Registry"
import { Context, Layer, Effect, pipe, Data } from "effect"
// State machine
export type WalletState = Data.TaggedEnum<{
Disconnected: {}
Connecting: {}
Connected: { displayAddress: string; fullAddress: string }
}>
export const WalletState = Data.taggedEnum<WalletState>()
// 1. Interface - atoms use camelCase with $ suffix
export interface WalletVM {
readonly state$: Atom.Atom<WalletState>
readonly isConnected$: Atom.Atom<boolean> // Derived, UI-ready
readonly connect: () => void // Actions return void
readonly disconnect: () => void
}
// 2. Tag
export const WalletVM = Context.GenericTag<WalletVM>("WalletVM")
// 3. Layer - atoms ONLY defined inside the layer
// VMRuntime provides scope, so Layer.effect is the default
const layer = Layer.effect(
WalletVM,
Effect.gen(function* () {
const registry = yield* AtomRegistry
const walletService = yield* WalletService
// Atoms defined here, inside the layer
const state$ = Atom.make<WalletState>(WalletState.Disconnected())
const isConnected$ = pipe(state$, Atom.map(WalletState.$is("Connected")))
const connect = () => {
registry.set(state$, WalletState.Connecting())
Effect.runPromise(
walletService.connect.pipe(
Effect.match({
onFailure: () => registry.set(state$, WalletState.Disconnected()),
onSuccess: (addr) => registry.set(state$, WalletState.Connected({
displayAddress: `${addr.slice(0,6)}...${addr.slice(-4)}`,
fullAddress: addr
}))
})
)
)
}
const disconnect = () => {
registry.set(state$, WalletState.Disconnected())
}
return { state$, isConnected$, connect, disconnect }
})
)
// 4. Default export
export default { tag: WalletVM, layer }
```
---
## Component Pattern
```tsx
// components/Wallet/Wallet.tsx
"use client"
import { useVM } from "@/lib/VMRuntime"
import { useAtomValue } from "@effect-atom/atom-react"
import * as Result from "@effect-atom/atom/Result"
import WalletVM, { WalletState, type WalletVM as WalletVMType } from "./Wallet.vm"
// Child components receive VM as prop - no own VM needed
function WalletStatus({ vm }: { vm: WalletVMType }) {
const state = useAtomValue(vm.state$)
return WalletState.$match(state, {
Disconnected: () => <span>Not connected</span>,
Connecting: () => <Spinner />,
Connected: ({ displayAddress }) => <span>{displayAddress}</span>
})
}
function WalletActions({ vm }: { vm: WalletVMType }) {
const isConnected = useAtomValue(vm.isConnected$)
return isConnected
? <button onClick={vm.disconnect}>Disconnect</button>
: <button onClick={vm.connect}>Connect</button>
}
// Parent component owns VM
export default function Wallet() {
const vmResult = useVM(WalletVM.tag, WalletVM.layer)
return Result.match(vmResult, {
onInitial: () => <Spinner />,
onSuccess: ({ value: vm }) => (
<div className="wallet">
<WalletStatus vm={vm} />
<WalletActions vm={vm} />
</div>
),
onFailure: ({ cause }) => <Alert>{String(cause)}</Alert>
})
}
```
---
## Core Pattern: Atom.fn for Async Actions
**Key insight**: Use `Atom.fn` with `Effect.fnUntraced` for effect-based actions. This gives you:
1. Automatic `waiting` flag for loading state
2. `Result<Success, Error>` with Initial/Success/Failure states
3. No manual state management or void wrappers
```tsx
import { Atom, useAtomValue, useAtomSet } from "@effect-atom/atom-react"
import * as Result from "@effect-atom/atom/Result"
import { Effect, Exit } from "effect"
// Define action with Atom.fn + Effect.fnUntraced
const refreshAtom = Atom.fn(
Effect.fnUntraced(function* () {
const consents = yield* consentService.getOwnConsents
return consents
})
)
// In component - useAtom for result and trigger
function ConsentList() {
const [result, refresh] = useAtom(refreshAtom)
// result.waiting is true while the effect runs
const isLoading = result.waiting
return (
<div>
<button onClick={() => refresh()} disabled={isLoading}>
{isLoading ? "Loading..." : "Refresh"}
</button>
{Result.matchWithWaiting(result, {
onWaiting: () => <Loading />,
onSuccess: ({ value }) => <List items={value} />,
onError: (error) => <Error message={String(error)} />,
onDefect: (defect) => <Error message={String(defect)} />
})}
</div>
)
}
```
**With services using Atom.runtime:**
```tsx
class ConsentService extends Effect.Service<ConsentService>()("ConsentService", {
effect: Effect.gen(function* () {
const getAll = Effect.succeed([{ id: "1", name: "Terms" }])
return { getAll } as const
}),
}) {}
const runtimeAtom = Atom.runtime(ConsentService.Default)
const refreshAtom = runtimeAtom.fn(
Effect.fnUntraced(function* () {
const service = yield* ConsentService
return yield* service.getAll
})
)
```
**With promiseExit for async handlers:**
```tsx
function CreateUser() {
// mode: "promiseExit" returns Promise<Exit<...>> for await
const createUser = useAtomSet(createUserAtom, { mode: "promiseExit" })
return (
<button onClick={async () => {
const exit = await createUser("John")
if (Exit.isSuccess(exit)) {
console.log(exit.value)
}
}}>
Create
</button>
)
}
```
**Anti-pattern: Manual void wrappers**
```typescript
// ❌ DON'T - manual state management loses waiting control
const loading$ = Atom.make(false)
const data$ = Atom.make<Data | null>(null)
const refresh = (): void => {
registry.set(loading$, true)
Effect.runPromise(fetchData).then(data => {
registry.set(data$, data)
registry.set(loading$, false)
})
}
// ✅ DO - Atom.fn handles everything
const refreshAtom = Atom.fn(Effect.fnUntraced(function* () {
return yield* fetchData
}))
// result.waiting, Result.matchWithWaiting - all built-in
```
---
## Building Blocks
### Atoms & Registry
Atoms are ONLY defined inside VM layers:
```typescript
// Inside Layer.effect or Layer.scoped
const registry = yield* AtomRegistry
// Writable atom - camelCase with $ suffix
const count$ = Atom.make(0)
// Derived atom (read-only)
const doubled$ = pipe(count$, Atom.map((n) => n * 2))
// Read/write via registry
registry.get(count$) // read
registry.set(count$, 42) // write
```
### Data.TaggedEnum - State Machines
```tsx
export type WalletState = Data.TaggedEnum<{
Disconnected: {}
Connecting: {}
Connected: { displayAddress: string; fullAddress: string }
}>
export const WalletState = Data.taggedEnum<WalletState>()
// Pattern match in UI
WalletState.$match(state, {
Disconnected: () => <ConnectButton />,
Connecting: () => <Spinner />,
Connected: ({ displayAddress }) => <span>{displayAddress}</span>
})
```
### VMs with Lists (Atom.family)
```typescript
const makeConsentItemVM = Atom.family((consent: Consent): ConsentItemVM => {
const status$ = pipe(consentsState$, Atom.map((either) =>
Either.match(either, {
onLeft: () => ConsentStatus.Active(),
onRight: (consents) => {
const c = consents.find(x => x.consentId === consent.consentId)
return c?.isRevoked ? ConsentStatus.Revoked() : ConsentStatus.Active()
}
})
))
// Close over consent.consentId - UI never sees it
const revoke = () => {
Effect.gen(function* () {
yield* consentService.revokeById(consent.consentId)
yield* refresh()
}).pipe(Effect.runFork)
}
return { key: consent.consentId, status$, revoke }
})
```
### Event Listeners → Atom with Finalizer
Instead of `useEffect` for event listeners, use `Atom.make` with `get.addFinalizer`:
```typescript
// Window scroll position - auto-cleanup when atom is no longer used
const scrollY$ = Atom.make((get) => {
const onScroll = () => get.setSelf(window.scrollY)
window.addEventListener("scroll", onScroll)
get.addFinalizer(() => window.removeEventListener("scroll", onScroll))
return window.scrollY
})
// Resize observer
const windowSize$ = Atom.make((get) => {
const update = () => get.setSelf({ width: window.innerWidth, height: window.innerHeight })
window.addEventListener("resize", update)
get.addFinalizer(() => window.removeEventListener("resize", update))
return { width: window.innerWidth, height: window.innerHeight }
})
```
### URL Search Params → Atom.searchParam
Instead of `useEffect` + `useSearchParams`, use `Atom.searchParam`:
```typescript
// Simple string param
const filter$ = Atom.searchParam("filter") // Atom.Writable<string>
// With schema parsing
const page$ = Atom.searchParam("page", {
schema: Schema.NumberFromString
}) // Atom.Writable<Option<number>>
// Multiple params for a search form
const search$ = Atom.searchParam("q")
const sort$ = Atom.searchParam("sort")
const limit$ = Atom.searchParam("limit", { schema: Schema.NumberFromString })
```
---
## VMRuntime Hook
```typescript
// lib/VMRuntime.ts
const memoMap = Layer.makeMemoMap.pipe(Effect.runSync)
const vmAtom = Atom.family(<Id, Value, E>(key: VmKey<Id, Value, E>) =>
Atom.make(
Effect.gen(function* () {
const scope = yield* Scope.Scope
const ctx = yield* Layer.buildWithMemoMap(key.layer, memoMap, scope)
return Context.get(ctx, key.tag)
})
)
)
export const useVM = <Id, Value, E>(
tag: Context.Tag<Id, Value>,
layer: Layer.Layer<Id, E, Scope.Scope | AtomRegistry>
): Result.Result<Value, E> => useAtomValue(vmAtom(makeVmKey(tag, layer)))
```
---
## React Integration
### Provider Setup
```tsx
// app/providers.tsx
import { RegistryProvider } from "@effect-atom/atom-react"
export function Providers({ children }: { children: React.ReactNode }) {
return <RegistryProvider>{children}</RegistryProvider>
}
```
### Hooks Reference
| Hook | Purpose |
|------|---------|
| `useAtomValue(atom$)` | Subscribe to value |
| `useAtomSet(atom$)` | Get setter function |
| `useAtom(atom$)` | Get `[value, setter]` |
---
## Testing VMs
```typescript
describe("WalletVM", () => {
const WalletServiceMock = Layer.succeed(WalletService, WalletService.of({
connect: Effect.succeed("0x1234..."),
disconnect: Effect.succeed(undefined)
}))
const makeVM = () => {
const r = Registry.make()
const vm = Layer.build(WalletVM.layer).pipe(
Effect.map((ctx) => Context.get(ctx, WalletVM.tag)),
Effect.scoped,
Effect.provideService(Registry.AtomRegistry, r),
Effect.provide(WalletServiceMock),
Effect.runSync
)
return { r, vm }
}
it("should start disconnected", () => {
const { r, vm } = makeVM()
expect(WalletState.$is("Disconnected")(r.get(vm.state$))).toBe(true)
})
it("should connect wallet", async () => {
const { r, vm } = makeVM()
vm.connect()
await new Promise(r => setTimeout(r, 10))
expect(WalletState.$is("Connected")(r.get(vm.state$))).toBe(true)
})
})
```
---
## Best Practices
**Core Pattern**
- Use `Atom.fn()` for async actions—gives you `AtomResultFn` with automatic `waiting` flag
- Use `useAtom(action$)` to get `[result, trigger]` tuple
- `Result.matchWithWaiting` for rendering async states (onWaiting/onSuccess/onError/onDefect)
- `Result.match` for one-time builds like VM initialization (onInitial/onSuccess/onFailure)
- Never manually wrap Effects in void functions—you lose `waiting` control
**Naming & Structure**
- Atoms use `camelCase$` suffix
- Every parent component: `Component.tsx` + `Component.vm.ts`
- Child components receive VM as prop (no own VM)
- VM file exports: interface, tag, default `{ tag, layer }`
**Interface Design**
- ALL formatting happens in VM—components receive ready-to-render strings
- Use `key` for React, close over IDs in callbacks
### UI-Ready Output Examples
```tsx
// WRONG - Logic in component
function UserCard({ vm }: { vm: UserVM }) {
const user = useAtomValue(vm.user$)
const balance = useAtomValue(vm.balance$)
// NO! Formatting in component
const displayName = `${user.firstName} ${user.lastName.charAt(0)}.`
const formattedBalance = new Intl.NumberFormat('en-US', {
style: 'currency', currency: 'USD'
}).format(balance / 100)
const isVip = balance > 10000 && user.memberSince < Date.now() - 31536000000
return (
<div>
<h2>{displayName}</h2>
<span>{formattedBalance}</span>
{isVip && <VipBadge />} {/* NO! Conditional logic */}
</div>
)
}
// CORRECT - VM produces UI-ready values
interface UserVM {
readonly displayName$: Atom.Atom<string> // "John D."
readonly formattedBalance$: Atom.Atom<string> // "$1,234.56"
readonly showVipBadge$: Atom.Atom<boolean> // true/false
}
function UserCard({ vm }: { vm: UserVM }) {
const displayName = useAtomValue(vm.displayName$)
const formattedBalance = useAtomValue(vm.formattedBalance$)
const showVipBadge = useAtomValue(vm.showVipBadge$)
return (
<div>
<h2>{displayName}</h2>
<span>{formattedBalance}</span>
{showVipBadge && <VipBadge />} {/* OK - just reading a boolean */}
</div>
)
}
```
**Implementation**
- Atoms ONLY defined inside VM layers
- `Layer.effect` is the default (VMRuntime provides scope)
- Use `Atom.family` for list item sub-VMs
- Use `Effect.forkScoped` for background tasks
- Handle all errors in actions (update atom on failure)
**Testing**
- Test VMs without UI using registry directly
- Create fresh VM per test
- Mock services with `Layer.succeed`Quick Install
$
npx ai-builder add skill kriegcloud/react-vmDetails
- Type
- skill
- Author
- kriegcloud
- Slug
- kriegcloud/react-vm
- Created
- 2w ago