Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component...
npx skills add front-depiction/claude-setup --skill "react-vm"
Install specific skill from multi-skill repository
# 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.
# SKILL.md
name: react-vm
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.
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. |
// ❌ 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
}))
})
)
)
}
const disconnect = () => {
registry.set(state$, WalletState.Disconnected())
}
return { state$, isConnected$, connect, disconnect }
})
)
// 4. Default export
export default { tag: WalletVM, layer }
Component Pattern
// 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
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:
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:
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
// ❌ 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:
// 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
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)
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:
// 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:
// 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
// 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
// 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
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
// 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
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.