react-vm
from front-depiction/claude-setup
Reusable Claude Code configuration for Effect TypeScript projects with specialized agents and skills
npx skills add https://github.com/front-depiction/claude-setup --skill react-vmSKILL.md
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:
Userentity →displayName: "John D."timestamp: 1702425600→formattedDate: "Dec 13, 2024"balance: 1000000n→displayBalance: "$1,000,000"isActive && hasAccess→canEdit: trueerror.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. |
// ❌ 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:
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 viaLayer.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 →
useAtomValuere-renders
VM File Pattern
Each VM file contains: interface, tag, and default { tag, layer } export.
// 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
}))
})
...