| name | optimistic-updates |
| description | Optimistic UI patterns for ChainGraph frontend. Use when working on real-time collaboration, port value updates, node position syncing, debouncing, echo detection, or any client-server state synchronization. Covers 3-step echo detection, pending mutations, position interpolation. Triggers: optimistic, echo detection, pending mutation, debounce, throttle, position interpolation, staleness, real-time, collaboration. |
Optimistic Updates Patterns
This skill covers the optimistic update patterns used in ChainGraph frontend for responsive UI during client-server synchronization.
Pattern Overview
┌──────────────────────────────────────────────────────────────┐
│ OPTIMISTIC UPDATE FLOW │
│ │
│ User Input → Local Update → Server Request → Echo Detection │
│ │ │ │ │ │
│ │ ▼ │ ▼ │
│ │ Immediate UI │ Filter Own Echo │
│ │ ▼ │
│ │ Server Confirms │
│ │ │ │
│ └─────────────────────────────┴──────────────────────────│
│ Final State Consistent │
└──────────────────────────────────────────────────────────────┘
Core Concepts
1. Immediate Local Update
Update UI immediately when user acts, don't wait for server.
2. Debounced Server Sync
Batch rapid changes before sending to server.
3. Echo Detection
When server broadcasts the change back, filter out "echoes" of our own changes.
4. Pending Mutation Tracking
Track what we've sent to detect and match echoes correctly.
Echo Detection (3-Step)
File: apps/chaingraph-frontend/src/store/ports-v2/echo-detection.ts
When a port update arrives from the server, it could be:
- Our own echo - Confirmation of our optimistic update
- Stale update - Older than our pending changes
- Other user's change - Genuine new data to apply
3-Step Detection Algorithm
const matchedMutation = pendingMutations.find(m =>
m.version === event.version &&
isDeepEqual(m.value, event.changes.value)
)
if (matchedMutation) {
confirmPendingMutation({ portKey, mutationId: matchedMutation.mutationId })
return
}
const latestPending = pendingMutations
.sort((a, b) => b.version - a.version)[0]
if (latestPending && event.version < latestPending.version) {
return
}
const currentValue = $portValues.get(portKey)
if (isDeepEqual(currentValue, event.changes.value)) {
return
}
applyPortUpdate(event)
Pending Mutations
File: apps/chaingraph-frontend/src/store/ports-v2/pending-mutations.ts
PendingMutation Interface
interface PendingMutation {
portKey: string
value: unknown
version: number
timestamp: number
mutationId: string
clientId: string
}
Store Structure
export const $pendingPortMutations = portsV2Domain
.createStore<Map<PortKey, PendingMutation[]>>(new Map())
.reset(globalReset)
export const addPendingMutation = portsV2Domain.createEvent<PendingMutation>()
export const confirmPendingMutation = portsV2Domain.createEvent<{
portKey: PortKey
mutationId: string
}>()
export const rejectPendingMutation = portsV2Domain.createEvent<{
portKey: PortKey
mutationId: string
reason: string
}>()
Usage Pattern
function handlePortChange(portKey: string, newValue: unknown) {
const mutationId = generateMutationId()
const version = getCurrentVersion(portKey) + 1
addPendingMutation({
portKey,
value: newValue,
version,
timestamp: Date.now(),
mutationId,
clientId: getClientId(),
})
updatePortValueLocal({ portKey, value: newValue })
debouncedServerUpdate({ portKey, value: newValue, version })
}
Debounce/Throttle Constants
File: apps/chaingraph-frontend/src/store/nodes/constants.ts
export const NODE_POSITION_DEBOUNCE_MS = 500
export const NODE_DIMENSIONS_DEBOUNCE_MS = 500
export const NODE_UI_DEBOUNCE_MS = 250
export const LOCAL_NODE_UI_DEBOUNCE_MS = 1000 / 90
export const PORT_VALUE_THROTTLE_MS = 500
Debounce Pattern
import { debounce } from 'patronum'
const updateNodePositionFx = nodesDomain.createEffect(
async (params: PositionUpdate) => {
return trpcClient.flow.updateNodePosition.mutate(params)
}
)
const debouncedPositionUpdate = debounce({
source: nodePositionChanged,
timeout: NODE_POSITION_DEBOUNCE_MS,
})
sample({
clock: debouncedPositionUpdate,
target: updateNodePositionFx,
})
Position Interpolation
File: apps/chaingraph-frontend/src/store/nodes/position-interpolation-advanced.ts
Smooth animations for node positions during drag and server updates.
Spring Physics Model
class PositionInterpolator {
private tension = 180
private friction = 12
private positions: Map<string, { x: number, y: number }>
private velocities: Map<string, { vx: number, vy: number }>
private targets: Map<string, { x: number, y: number }>
update(nodeId: string, targetX: number, targetY: number) {
this.targets.set(nodeId, { x: targetX, y: targetY })
this.startAnimation()
}
private tick() {
for (const [nodeId, target] of this.targets) {
const pos = this.positions.get(nodeId)
const vel = this.velocities.get(nodeId)
const dx = target.x - pos.x
const dy = target.y - pos.y
const ax = dx * this.tension - vel.vx * this.friction
const ay = dy * this.tension - vel.vy * this.friction
vel.vx += ax * dt
vel.vy += ay * dt
pos.x += vel.vx * dt
pos.y += vel.vy * dt
}
}
}
export const positionInterpolator = new PositionInterpolator()
Usage
sample({
clock: nodePositionReceived,
fn: ({ nodeId, x, y }) => {
positionInterpolator.update(nodeId, x, y)
},
})
sample({
clock: nodeDragged,
fn: ({ nodeId, x, y }) => {
positionInterpolator.setImmediate(nodeId, x, y)
},
})
Optimistic Update Pattern (Complete)
Port Value Update
const handleInputChange = (portKey: string, value: string) => {
const mutationId = nanoid()
addPendingMutation({
portKey,
value,
version: nextVersion,
timestamp: Date.now(),
mutationId,
clientId,
})
setPortValueLocal({ portKey, value })
}
sample({
clock: debounce({ source: setPortValueLocal, timeout: 300 }),
target: updatePortValueFx,
})
sample({
clock: portUpdateReceived,
source: {
pending: $pendingPortMutations,
values: $portValues,
},
fn: ({ pending, values }, event) => {
const matched = findMatchingMutation(pending, event)
if (matched) {
return { confirm: matched.mutationId }
}
if (isStale(pending, event)) {
return { drop: true }
}
if (isDuplicate(values, event)) {
return { drop: true }
}
return { apply: event }
},
target: spread({
confirm: confirmPendingMutation,
apply: applyPortUpdate,
}),
})
Key Files
| File | Purpose |
|---|
store/ports-v2/echo-detection.ts | 3-step echo filtering |
store/ports-v2/pending-mutations.ts | Mutation tracking |
store/nodes/position-interpolation-advanced.ts | Smooth animations |
store/nodes/stores.ts | Debounce constants |
store/flow/event-buffer.ts | Event batching |
Anti-Patterns
Anti-Pattern #1: Not tracking mutations
const handleChange = (value) => {
setPortValueLocal(value)
updatePortValueFx(value)
}
const handleChange = (value) => {
const mutationId = nanoid()
addPendingMutation({ portKey, value, mutationId, ... })
setPortValueLocal(value)
updatePortValueFx(value)
}
Anti-Pattern #2: Not debouncing rapid updates
input.oninput = (e) => {
updateServerFx(e.target.value)
}
input.oninput = (e) => {
setLocalValue(e.target.value)
}
sample({
clock: debounce({ source: setLocalValue, timeout: 300 }),
target: updateServerFx,
})
Anti-Pattern #3: Ignoring staleness
portUpdateReceived.watch((event) => {
setPortValue(event.value)
})
sample({
clock: portUpdateReceived,
source: $pendingPortMutations,
filter: (pending, event) => {
const latest = getLatestPendingVersion(pending, event.portKey)
return !latest || event.version >= latest
},
target: applyPortUpdate,
})
Quick Reference
| Need | Pattern | File |
|---|
| Track local changes | addPendingMutation() | pending-mutations.ts |
| Filter echoes | 3-step detection | echo-detection.ts |
| Debounce updates | debounce({ timeout: X }) | patronum |
| Smooth animations | positionInterpolator | position-interpolation-advanced.ts |
| Batch events | Event buffer | event-buffer.ts |
Related Skills
effector-patterns - Effector patterns for state management
subscription-sync - Server subscription handling
frontend-architecture - Overall frontend structure
port-system - Port value management