Unnamed Skill

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.

$ インストール

git clone https://github.com/chaingraphlabs/chaingraph /tmp/chaingraph && cp -r /tmp/chaingraph/.claude/skills/effector-patterns ~/.claude/skills/chaingraph

// tip: Run this command in your terminal to install the skill


name: effector-patterns description: 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.

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
flowDomain17Flow list, active flow, metadata
nodesDomain20Node CRUD, positions, dimensions
edgesDomain23Edge connections, anchors, selection
executionDomain26Execution state, events, control
categoriesDomain29Node categories, filtering
portsDomain32Legacy port management
trpcDomain35tRPC client instances
archaiDomain38ArchAI integration
focusedEditorsDomain41Port editor focus state
dragDropDomain44Drag & drop state
mcpDomain47MCP server management
initializationDomain50App initialization
walletDomain53Wallet integration
hotkeysDomainhotkeys/stores.tsKeyboard shortcuts (not in domains.ts)
xyflowDomainxyflow/domain.tsXYFlow render (not in domains.ts)
perfTraceDomainperf-trace/domain.tsPerformance (not in domains.ts)
portsV2Domainports-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 storessample({ source, clock, fn })Reactive computation
Effect needs store valueattach({ source, effect })tRPC calls
Merge storescombine(stores, fn)Error aggregation
Time-based batchinginterval({ timeout, start, stop })Event buffer
Distribute to multiple targetsspread({ ... })Port updates
Reset on app state change.reset(globalReset)All stores
Read store in componentuseUnit([$store, event])React integration

Key Files

FilePurpose
src/store/domains.tsAll domain definitions
src/store/common.tsglobalReset event
src/store/flow/event-buffer.tsPatronum interval example
src/store/ports-v2/buffer.tsPatronum spread example
src/store/edges/stores.tssample/attach examples

Related Skills

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