react-vm

from front-depiction/claude-setup

Reusable Claude Code configuration for Effect TypeScript projects with specialized agents and skills

10 stars4 forksUpdated Jan 19, 2026
npx skills add https://github.com/front-depiction/claude-setup --skill react-vm

SKILL.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:

  • User entity → displayName: "John D."
  • timestamp: 1702425600formattedDate: "Dec 13, 2024"
  • balance: 1000000ndisplayBalance: "$1,000,000"
  • isActive && hasAccesscanEdit: true
  • error.codeerrorMessage: "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.

PatternWhen to UseLocation
VMLayer serves a React componentcomponents/X/X.vm.ts paired with X.tsx
Service LayerNon-UI logic, shared business rulesservices/, 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 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.

// 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
            }))
          })
      

...
Read full content

Repository Stats

Stars10
Forks4