edge
Respond when a selected piece of state crosses an edge.
Demo
Every button calls the sync function. Pick the active phase and the call still runs, but the transition handler is skipped because the selected value did not change.
sync calls
0transitions detected0last callnot called yetNo transitions detected yet.
'use client'
import { edge } from '@joycostudio/xyz'
import { useRef, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Cluster } from '@/components/ui/cluster'
import { DemoPanel, ValueRow, ValueRows } from '@/components/demos/demo-ui'
type Phase = 'idle' | 'loading' | 'ready'
type Transition = { id: number; next: Phase; previous?: Phase }
type Detector = { sync: (state: { phase: Phase }) => void; fired: boolean }
/** Wraps `edge` so a caller can tell, synchronously, whether the handler ran. */
function createDetector(onTransition: (next: Phase, previous: Phase | undefined) => void) {
const detector: Detector = { sync: () => undefined, fired: false }
detector.sync = edge(
(state: { phase: Phase }) => state.phase,
(next, previous) => {
detector.fired = true
onTransition(next, previous)
}
)
return detector
}
export default function EdgeDemo() {
const [phase, setPhase] = useState<Phase>('idle')
const [transitions, setTransitions] = useState<Transition[]>([])
const [calls, setCalls] = useState(0)
const [fired, setFired] = useState<boolean | null>(null)
const detector = useRef<Detector | null>(null)
const push = (next: Phase, previous: Phase | undefined) =>
setTransitions((current) => [{ id: current.length + 1, next, previous }, ...current])
const call = (next: Phase) => {
const current = (detector.current ??= createDetector(push))
current.fired = false
current.sync({ phase: next })
setPhase(next)
setCalls((value) => value + 1)
setFired(current.fired)
}
const reset = () => {
detector.current = null
setPhase('idle')
setTransitions([])
setCalls(0)
setFired(null)
}
return (
<DemoPanel description="Every button calls the sync function. Pick the active phase and the call still runs, but the transition handler is skipped because the selected value did not change.">
<Cluster wrap>
{(['idle', 'loading', 'ready'] as const).map((value) => (
<Button key={value} variant={phase === value ? 'accent' : 'secondary'} onClick={() => call(value)}>
{value}
</Button>
))}
<Button variant="ghost" onClick={reset}>
Reset
</Button>
</Cluster>
<ValueRows className="mt-5">
<ValueRow label="sync calls">{calls}</ValueRow>
<ValueRow label="transitions detected">{transitions.length}</ValueRow>
<ValueRow label="last call">
{fired === null ? 'not called yet' : fired ? 'handler ran' : 'skipped — value unchanged'}
</ValueRow>
</ValueRows>
{transitions.length === 0 ? (
<p className="mt-5 bg-muted px-3 py-4 text-sm text-muted-foreground">No transitions detected yet.</p>
) : (
<Cluster asChild direction="col" align="stretch" className="mt-5" bg="accent">
<ol aria-label="Detected transitions, newest first">
{transitions.map((transition) => (
<Cluster key={transition.id} asChild align="stretch" bg="accent">
<li>
<Badge variant="card" slicedCorners={false}>
#{String(transition.id).padStart(2, '0')}
</Badge>
<code className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 font-mono text-sm">
<span className="text-muted-foreground">{transition.previous ?? 'undefined'}</span>
<span aria-hidden="true">→</span>
<span>{transition.next}</span>
</code>
</li>
</Cluster>
))}
</ol>
</Cluster>
)}
</DemoPanel>
)
}
Usage
import { edge } from '@joycostudio/xyz'
const sync = edge(
(state: { phase: string }) => state.phase,
(next, previous) => console.log(previous, '→', next)
)
sync({ phase: 'loading' }) // undefined → loading
sync({ phase: 'loading' }) // unchanged: no callback
sync({ phase: 'ready' }) // loading → ready
API
edge
edge<State, Selected>(
select: (state: State) => Selected,
onChange: (next: Selected, previous: Selected | undefined, state: State) => void,
equals?: (left: Selected, right: Selected) => boolean
): (state: State) => void
Returns a state-sync function. It calls onChange on the first value and whenever the selected value changes.
| Argument | Description |
|---|---|
select | Reads the value to track from the supplied state. Runs on every sync call. |
onChange | Receives the next selected value, previous selected value, and current state. The previous value is undefined on the first call. |
equals? | Compares the next selected value with the previous one. Return true to skip the callback. Defaults to Object.is. |
The previous value is stored before onChange runs, so synchronous re-entry with the same selected value does not trigger another callback.
const sync = edge(
(state: { progress: number }) => Math.floor(state.progress * 100),
(percent, previous) => console.log(previous, '→', percent)
)
sync({ progress: 0.101 }) // undefined → 10
sync({ progress: 0.109 }) // unchanged: no callback
sync({ progress: 0.11 }) // 10 → 11
sync
sync(state: State): void
The function returned by edge. Call it whenever you want to check the current state for a transition.
| Argument | Description |
|---|---|
state | Current state passed to select and, when the value changes, to onChange. |
// Using the sync function above:
sync({ progress: 0.5 }) // 11 → 50
sync({ progress: 0.5 }) // unchanged: no callback