Claude-skill-registry effector-patterns

Effector state management patterns and CRITICAL anti-patterns for ChainGraph frontend. Use when writing Effector stores, events, effects, samples, or any reactive state code. Contains anti-patterns to AVOID like $store.getState(). Covers domains, patronum utilities, global reset. Triggers: effector, store, createStore, createEvent, createEffect, sample, combine, attach, domain, $, useUnit, getState, anti-pattern, patronum.

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/effector-patterns" ~/.claude/skills/majiayu000-claude-skill-registry-effector-patterns && rm -rf "$T"
manifest: skills/data/effector-patterns/SKILL.md
source content

Effector Patterns for ChainGraph

This skill covers Effector state management patterns used in the ChainGraph frontend, including CRITICAL anti-patterns that agents MUST avoid.

Domain Organization

ChainGraph uses domain-based store organization. All domains are defined in:

File:

apps/chaingraph-frontend/src/store/domains.ts

All Domains

DomainLinePurpose
flowDomain
17Flow list, active flow, metadata
nodesDomain
20Node CRUD, positions, dimensions
edgesDomain
23Edge connections, anchors, selection
executionDomain
26Execution state, events, control
categoriesDomain
29Node categories, filtering
portsDomain
32Legacy port management
trpcDomain
35tRPC client instances
archaiDomain
38ArchAI integration
focusedEditorsDomain
41Port editor focus state
dragDropDomain
44Drag & drop state
mcpDomain
47MCP server management
initializationDomain
50App initialization
walletDomain
53Wallet integration
hotkeysDomain
hotkeys/stores.tsKeyboard shortcuts (not in domains.ts)
xyflowDomain
xyflow/domain.tsXYFlow render (not in domains.ts)
perfTraceDomain
perf-trace/domain.tsPerformance (not in domains.ts)
portsV2Domain
ports-v2/domain.ts:23Granular ports (not in domains.ts)

Creating a Domain

import { createDomain } from 'effector'

// Naming: {feature}Domain with kebab-case internal name
export const myFeatureDomain = createDomain('my-feature')

CRITICAL Anti-Patterns

Anti-Pattern #1: Using
.getState()
in Store Reducers

This is the most common mistake. Found in 13+ files in the codebase.

// ❌ BAD: .getState() in reducer breaks reactivity
const $compatiblePorts = portsDomain.createStore<string[] | null>(null)
  .on($draggingEdgePort, (state, draggingEdgePort) => {
    // This ONLY reads $nodes at call time, NOT reactively
    const nodes = Object.values($nodes.getState())  // ← ANTI-PATTERN
    // ...
    return compatiblePorts
  })

Why it's wrong:

  • .getState()
    bypasses Effector's dependency tracking
  • Updates to
    $nodes
    won't trigger updates to
    $compatiblePorts
  • No subscription established - reads value once at call time
  • Breaks the reactive data flow model
// ✅ GOOD: Use sample() for reactive derivation
const $compatiblePorts = sample({
  source: { nodes: $nodes, draggingPort: $draggingEdgePort },
  clock: $draggingEdgePort,
  fn: ({ nodes, draggingPort }) => {
    if (!draggingPort) return null
    const nodeList = Object.values(nodes)
    // ... compute compatible ports
    return compatiblePorts
  },
})

Where
.getState()
IS Acceptable

Only use

.getState()
in these specific cases:

  1. Inside effect handlers (when you truly need a snapshot):

    const myEffectFx = createEffect(async (params) => {
      // OK: Effect runs once, needs current value
      const client = $trpcClient.getState()
      return client.mutation(params)
    })
    
  2. Better: Use

    attach()
    instead:

    // ✅ BEST: Explicit dependency via attach()
    const myEffectFx = attach({
      source: $trpcClient,
      effect: async (client, params) => {
        return client.mutation(params)
      },
    })
    

Correct Patterns

Pattern 1:
sample()
- Reactive Derivation

Use

sample()
when you need to combine multiple sources reactively:

File:

apps/chaingraph-frontend/src/store/edges/stores.ts:126-151

// Derive dragging port data from nodes and dragging edge
const $draggingEdgePortUpdated = sample({
  source: $nodes,                    // Reactive source
  clock: $draggingEdge,              // When to sample
  fn: (nodes, draggingEdge) => {     // Transform function
    if (!draggingEdge?.nodeId || !draggingEdge?.handleId) {
      return null
    }
    const node = nodes[draggingEdge.nodeId]
    if (!node) return null

    const draggingPort = node.getPort(draggingEdge.handleId)
    return draggingPort ? { draggingEdge, draggingPort } : null
  },
})

Pattern 2:
attach()
- Effect with Source

Use

attach()
when effects need store values:

File:

apps/chaingraph-frontend/src/store/edges/stores.ts:46-74

// Effect that needs tRPC client
const addEdgeFx = attach({
  source: $trpcClient,
  effect: async (client, event: AddEdgeEventData) => {
    if (!client) {
      throw new Error('TRPC client is not initialized')
    }
    return client.flow.connectPorts.mutate({
      flowId: event.flowId,
      sourceNodeId: event.sourceNodeId,
      sourcePortId: event.sourcePortId,
      targetNodeId: event.targetNodeId,
      targetPortId: event.targetPortId,
    })
  },
})

Pattern 3:
combine()
- Merge Stores

Use

combine()
to create derived stores from multiple sources:

File:

apps/chaingraph-frontend/src/store/flow/stores.ts:300-308

// Combine multiple error states
export const $allFlowsErrors = combine(
  $flowsError,
  $createFlowError,
  $updateFlowError,
  $deleteFlowError,
  $forkFlowError,
  (loadError, createError, updateError, deleteError, forkError) =>
    loadError || createError || updateError || deleteError || forkError,
)

// Object syntax (creates named object)
export const $flowSubscriptionState = combine({
  status: $flowSubscriptionStatus,
  error: $flowSubscriptionError,
  isSubscribed: $isFlowSubscribed,
})

Pattern 4: Advanced
sample()
with Multiple Clocks

File:

apps/chaingraph-frontend/src/store/edges/stores.ts:335-393

// React to multiple events with named source object
sample({
  clock: [$portConfigs, $portUI, setEdges, setEdge, $xyflowNodesList],
  source: {
    edgeMap: $edgeRenderMap,
    portConfigs: $portConfigs,
    portUI: $portUI,
    xyflowNodes: $xyflowNodesList,
  },
  fn: ({ edgeMap, portConfigs, portUI, xyflowNodes }) => {
    const changes: Array<{ edgeId: string, changes: Partial<EdgeRenderData> }> = []

    for (const [edgeId, edge] of edgeMap) {
      const sourceKey = toPortKey(edge.source, edge.sourceHandle)
      const sourceConfig = portConfigs.get(sourceKey)
      // ... compute changes
    }

    return { changes }
  },
  target: edgeDataChanged,
})

Global Reset Pattern

All stores should support global reset for clean state transitions:

File:

apps/chaingraph-frontend/src/store/common.ts

import { createEvent } from 'effector'

export const globalReset = createEvent()

Usage in stores:

export const $edges = edgesDomain.createStore<EdgeData[]>([])
  .on(setEdges, (source, edges) => [...source, ...edges])
  .on(removeEdge, (edges, event) => edges.filter(e => e.edgeId !== event.edgeId))
  .reset(resetEdges)      // Domain-specific reset
  .reset(globalReset)     // Global reset (ALWAYS add this)

Patronum Utilities

ChainGraph uses patronum for advanced patterns:

interval
- Time-based Events

File:

apps/chaingraph-frontend/src/store/flow/event-buffer.ts

import { interval } from 'patronum'

// Create periodic ticker for event batching
const ticker = interval({
  timeout: 50,           // 50ms interval
  start: tickerStart,    // Event to start ticker
  stop: tickerStop,      // Event to stop ticker
})

// Auto-start when buffer gets first event
sample({
  clock: flowEventReceived,
  source: $flowEventBuffer,
  filter: buffer => buffer.length === 1,  // First event
  target: tickerStart,
})

// Auto-stop when buffer is empty
sample({
  clock: $flowEventBuffer,
  filter: buffer => buffer.length === 0,
  target: tickerStop,
})

spread
- Distribute Events

File:

apps/chaingraph-frontend/src/store/ports-v2/buffer.ts

import { spread } from 'patronum'

// Spread port updates to multiple targets
sample({
  clock: portUpdatesReceived,
  fn: processPortUpdates,
  target: spread({
    valueUpdates: applyValueUpdates,
    uiUpdates: applyUIUpdates,
    configUpdates: applyConfigUpdates,
    connectionUpdates: applyConnectionUpdates,
  }),
})

debug
- Development Debugging

File:

apps/chaingraph-frontend/src/store/ports-v2/domain.ts

import { debug } from 'patronum'

// Enable in development (commented out in production)
// debug(portsV2Domain)

React Integration

Using
useUnit
(Recommended)

import { useUnit } from 'effector-react'

function MyComponent() {
  // ✅ GOOD: Destructure stores and events together
  const [nodes, selectedIds, selectNode] = useUnit([
    $nodes,
    $selectedNodeIds,
    selectNode,
  ])

  // Or with object syntax
  const { nodes, addNode } = useUnit({
    nodes: $nodes,
    addNode: addNodeEvent,
  })

  return <div onClick={() => addNode(newNode)}>{/* ... */}</div>
}

Avoid:
useStore
and
useEvent
separately

// ❌ AVOID: Separate hooks (less efficient)
const nodes = useStore($nodes)
const addNode = useEvent(addNodeEvent)

// ✅ PREFER: Combined useUnit
const [nodes, addNode] = useUnit([$nodes, addNodeEvent])

Store Organization Pattern

Standard Store File Structure

// stores.ts
import { sample, combine } from 'effector'
import { myDomain } from '../domains'
import { globalReset } from '../common'

// ============ EVENTS ============
export const doSomething = myDomain.createEvent<Payload>()
export const reset = myDomain.createEvent()

// ============ EFFECTS ============
export const doSomethingFx = myDomain.createEffect(async (payload: Payload) => {
  // async logic
})

// Or with attach for source dependency
export const doSomethingFx = attach({
  source: $dependency,
  effect: async (dep, payload) => {
    // async logic with dep
  },
})

// ============ STORES ============
export const $myStore = myDomain.createStore<State>(initialState)
  .on(doSomething, (state, payload) => newState)
  .on(doSomethingFx.doneData, (state, result) => newState)
  .reset(reset)
  .reset(globalReset)

// ============ DERIVED STORES ============
export const $derivedStore = combine($myStore, $otherStore, (my, other) => {
  // compute derived state
})

// ============ WIRING ============
sample({
  clock: someEvent,
  source: $myStore,
  filter: (state) => state.shouldTrigger,
  target: doSomethingFx,
})

Quick Reference

NeedPatternExample
Derive from multiple stores
sample({ source, clock, fn })
Reactive computation
Effect needs store value
attach({ source, effect })
tRPC calls
Merge stores
combine(stores, fn)
Error aggregation
Time-based batching
interval({ timeout, start, stop })
Event buffer
Distribute to multiple targets
spread({ ... })
Port updates
Reset on app state change
.reset(globalReset)
All stores
Read store in component
useUnit([$store, event])
React integration

Key Files

FilePurpose
src/store/domains.ts
All domain definitions
src/store/common.ts
globalReset event
src/store/flow/event-buffer.ts
Patronum interval example
src/store/ports-v2/buffer.ts
Patronum spread example
src/store/edges/stores.ts
sample/attach examples

Related Skills

  • frontend-architecture
    - Overall frontend structure
  • subscription-sync
    - How stores sync with backend
  • optimistic-updates
    - Optimistic UI patterns with Effector