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
| Domain | Line | Purpose |
|---|---|---|
flowDomain | 17 | Flow list, active flow, metadata |
nodesDomain | 20 | Node CRUD, positions, dimensions |
edgesDomain | 23 | Edge connections, anchors, selection |
executionDomain | 26 | Execution state, events, control |
categoriesDomain | 29 | Node categories, filtering |
portsDomain | 32 | Legacy port management |
trpcDomain | 35 | tRPC client instances |
archaiDomain | 38 | ArchAI integration |
focusedEditorsDomain | 41 | Port editor focus state |
dragDropDomain | 44 | Drag & drop state |
mcpDomain | 47 | MCP server management |
initializationDomain | 50 | App initialization |
walletDomain | 53 | Wallet integration |
hotkeysDomain | hotkeys/stores.ts | Keyboard shortcuts (not in domains.ts) |
xyflowDomain | xyflow/domain.ts | XYFlow render (not in domains.ts) |
perfTraceDomain | perf-trace/domain.ts | Performance (not in domains.ts) |
portsV2Domain | ports-v2/domain.ts:23 | Granular 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
$nodeswon'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:
-
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) }) -
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
| Need | Pattern | Example |
|---|---|---|
| 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
| File | Purpose |
|---|---|
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 structuresubscription-sync- How stores sync with backendoptimistic-updates- Optimistic UI patterns with Effector
Repository
