Created
April 14, 2025 04:06
-
-
Save sibbng/8e0954233f32d9e74a8bf26aa38aa760 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { render, Switch, h, useEffect, ObservableReadonly, Observable, If } from 'voby'; | |
import { $, useMemo, useReadonly, store } from 'voby'; | |
// --- Event & Context Types --- | |
type EventObject = { type: string; payload?: any }; | |
type GuardCondition<TContext, TEvent extends EventObject = EventObject> = | |
(context: TContext, event: TEvent) => boolean; | |
// --- Action Types --- | |
interface ActionContext<TContext extends object, TEvent extends { type: string }> { | |
context: TContext; // The reactive machine context | |
event: TEvent; // The event that triggered the action (or caused entry/exit) | |
} | |
type ActionParamsMap = Record<string, any>; | |
type ActionImplementationV2<TContext extends object, TEvent extends { type: string }, TParams = any> = | |
(actionContext: ActionContext<TContext, TEvent>, params?: TParams) => void; | |
type ActionDefinitionV2< | |
TContext extends object, | |
TEvent extends { type: string }, | |
TActions extends ActionParamsMap | |
> = | |
| { | |
[K in keyof TActions]: undefined extends TActions[K] | |
? K | { type: K; params?: TActions[K] } | |
: { type: K; params: TActions[K] } | |
}[keyof TActions] | |
| ((actionContext: ActionContext<TContext, TEvent>, params?: any) => void); | |
// --- Machine Definition Types --- | |
type TransitionDefinitionV2<TState extends string, TContext extends object, TEvent extends { type: string; payload?: any }, TActions extends ActionParamsMap> = | |
| TState | |
| { | |
target?: TState; | |
guard?: GuardCondition<TContext, TEvent>; | |
actions?: ActionDefinitionV2<TContext, TEvent, TActions>[]; | |
}; | |
type GuardedTransitionsV2<TState extends string, TContext extends object, TEvent extends { type: string; payload?: any }, TActions extends ActionParamsMap> = | |
TransitionDefinitionV2<TState, TContext, TEvent, TActions>[] | |
| TransitionDefinitionV2<TState, TContext, TEvent, TActions>; | |
interface StateNodeV2<TState extends string, TContext extends object, TEvent extends { type: string; payload?: any }, TActions extends ActionParamsMap> { | |
on?: Record<string, GuardedTransitionsV2<TState, TContext, TEvent, TActions>>; | |
entry?: ActionDefinitionV2<TContext, TEvent, TActions>[]; | |
exit?: ActionDefinitionV2<TContext, TEvent, TActions>[]; | |
after?: Record<number | string, GuardedTransitionsV2<TState, TContext, TEvent, TActions>>; | |
} | |
interface MachineDefinitionV2< | |
TState extends string, | |
TContext extends object, | |
TEvent extends { type: string; payload?: any }, | |
TActions extends ActionParamsMap, | |
TInput = void | |
> { | |
id: string; | |
initial: TState; | |
context?: TContext | ((opts: { input: TInput }) => TContext); | |
states: Record<TState, StateNodeV2<TState, TContext, TEvent, TActions>>; | |
actions: { | |
[K in keyof TActions]: ActionImplementationV2<TContext, TEvent, TActions[K]>; | |
}; | |
delays?: Record<string, number | ((opts: { context: TContext }) => number)>; | |
} | |
// --- Machine Instance Type --- | |
interface Machine<TState extends string, TContext extends object, TEvent extends { type: string; payload?: any }> { | |
state: ObservableReadonly<TState>; | |
context: TContext; | |
send: (event: TEvent) => void; | |
matches: (...states: TState[]) => ObservableReadonly<boolean>; | |
} | |
// --- Helper Functions --- | |
/** | |
* Helper function to execute an array of action definitions. | |
*/ | |
function executeActions< | |
TContext extends object, | |
TEvent extends { type: string; payload?: any; params?: any }, | |
TActions extends ActionParamsMap | |
>( | |
actionDefs: ActionDefinitionV2<TContext, TEvent, TActions>[] | undefined, | |
actionsMap: { [K in keyof TActions]: ActionImplementationV2<TContext, TEvent, TActions[K]> } | undefined, | |
actionContext: ActionContext<TContext, TEvent>, | |
machineId: string, | |
actionType: 'entry' | 'exit' | 'transition' | |
): void { | |
if (!actionDefs || !actionsMap) { | |
return; | |
} | |
actionDefs.forEach(actionDef => { | |
// Case 1: Action is a function | |
if (typeof actionDef === "function") { | |
try { | |
console.log(`[${machineId}] Executing ${actionType} inline action (function)`); | |
actionDef(actionContext); | |
} catch (error) { | |
console.error(`[${machineId}] Error executing ${actionType} inline action (function):`, error); | |
} | |
return; | |
} | |
let actionName: keyof TActions | undefined = undefined; | |
let params: any | undefined; | |
// Case 2: Action is a string | |
if (typeof actionDef === 'string') { | |
actionName = actionDef as keyof TActions; | |
} | |
// Case 3: Action is an object with type and optional params | |
else if (typeof actionDef === 'object' && actionDef !== null) { | |
actionName = actionDef.type; | |
params = actionDef.params; | |
} | |
if (actionName === undefined) { | |
console.warn(`[${machineId}] Skipping ${actionType} action: could not determine action name`, actionDef); | |
return; | |
} | |
const actionFn = actionsMap[actionName]; | |
if (actionFn) { | |
try { | |
if (params !== undefined) { | |
console.log(`[${machineId}] Executing ${actionType} action: ${String(actionName)} with params:`, params); | |
} else { | |
console.log(`[${machineId}] Executing ${actionType} action: ${String(actionName)}`); | |
} | |
actionFn(actionContext, params); | |
} catch (error) { | |
console.error(`[${machineId}] Error executing ${actionType} action '${String(actionName)}':`, error); | |
} | |
} else { | |
console.warn(`[${machineId}] Attempted to execute unknown ${actionType} action: ${String(actionName)}`); | |
} | |
}); | |
} | |
/** | |
* Creates a reactive state machine instance from a definition, | |
* supporting context, guards, and entry/exit/transition actions. | |
* Optionally accepts input for context initialization. | |
*/ | |
function createMachine< | |
TState extends string, | |
TContext extends object, | |
TEvent extends { type: string; payload?: any }, | |
TActions extends ActionParamsMap, | |
TInput = void | |
>( | |
definition: MachineDefinitionV2<TState, TContext, TEvent, TActions, TInput>, | |
input?: TInput | |
): Machine<TState, TContext, TEvent> { | |
const currentState: Observable<TState> = $(definition.initial); | |
// Type-safe context initialization | |
let initialContext: TContext; | |
if (typeof definition.context === "function") { | |
initialContext = (definition.context as (opts: { input: TInput }) => TContext)({ input: input as TInput }); | |
} else if (typeof definition.context === "object" && definition.context !== null) { | |
initialContext = definition.context as TContext; | |
} else { | |
initialContext = {} as TContext; | |
} | |
const machineContext = store<TContext>(initialContext); | |
const actionsMap = definition.actions; | |
// --- Delayed (after) transitions support --- | |
let afterTimers: Record<string, number> = {}; | |
function clearAfterTimers() { | |
if (!Object.keys(afterTimers).length) return; | |
console.log(`[${definition.id}] Clearing timers.`); | |
Object.values(afterTimers).forEach(timerId => clearTimeout(timerId)); | |
afterTimers = {}; | |
} | |
function scheduleAfterTransitions(stateValue: TState) { | |
const stateNode = definition.states[stateValue]; | |
if (!stateNode?.after) return; | |
Object.entries(stateNode.after).forEach(([delayKey, transitionDefRaw]) => { | |
console.log(`[${definition.id}] Scheduling after timer: key=${delayKey}, value=`, transitionDefRaw); | |
}); | |
Object.entries(stateNode.after).forEach(([delayKey, transitionDefRaw]) => { | |
let delayMs: number | undefined; | |
if (!Number.isNaN(Number(delayKey))) { | |
delayMs = Number(delayKey); | |
} else if (definition.delays && typeof delayKey === "string" && delayKey in definition.delays) { | |
const delayVal = definition.delays[delayKey]; | |
if (typeof delayVal === "function") { | |
delayMs = delayVal({ context: machineContext }); | |
} else { | |
delayMs = delayVal; | |
} | |
} | |
if (typeof delayMs !== "number" || Number.isNaN(delayMs)) { | |
console.warn(`[${definition.id}] Invalid after delay:`, delayKey); | |
return; | |
} | |
const transitionDefs = Array.isArray(transitionDefRaw) ? transitionDefRaw : [transitionDefRaw]; | |
const timerId = setTimeout(() => { | |
if (currentState() !== stateValue) return; | |
let selectedTransition: TransitionDefinitionV2<TState, TContext, TEvent, TActions> | undefined; | |
for (const t of transitionDefs) { | |
if (typeof t === 'string' || !t.guard) { | |
selectedTransition = t; | |
break; | |
} | |
try { | |
if (t.guard(machineContext, { type: 'after', payload: undefined } as unknown as TEvent)) { | |
selectedTransition = t; | |
break; | |
} | |
} catch (error) { | |
console.error(`[${definition.id}] Error executing guard for after transition in state '${stateValue}':`, error); | |
} | |
} | |
if (!selectedTransition) return; | |
let targetState: TState | undefined = undefined; | |
let transitionActions: ActionDefinitionV2<TContext, TEvent, TActions>[] | undefined = undefined; | |
if (typeof selectedTransition === 'string') { | |
targetState = selectedTransition; | |
} else { | |
targetState = selectedTransition.target; | |
transitionActions = selectedTransition.actions; | |
} | |
const actionCtx: ActionContext<TContext, TEvent> = { context: machineContext, event: { type: 'after', payload: undefined } as TEvent }; | |
if (targetState) { | |
const targetStateNode = definition.states[targetState]; | |
if (targetStateNode) { | |
console.log(`[${definition.id}] After transition: ${stateValue} --(after ${delayKey})--> ${targetState}`); | |
executeActions(stateNode.exit, actionsMap, actionCtx, definition.id, 'exit'); | |
executeActions(transitionActions, actionsMap, actionCtx, definition.id, 'transition'); | |
currentState(targetState); | |
executeActions(targetStateNode.entry, actionsMap, actionCtx, definition.id, 'entry'); | |
if (targetState === stateValue) { | |
clearAfterTimers(); | |
scheduleAfterTransitions(targetState); | |
} | |
} | |
} else if (transitionActions && transitionActions.length > 0) { | |
console.log(`[${definition.id}] After timer: executing actions only (no transition) for key=${delayKey}`); | |
executeActions(transitionActions, actionsMap, actionCtx, definition.id, 'transition'); | |
} | |
}, delayMs); | |
afterTimers[delayKey] = timerId as unknown as number; | |
}); | |
} | |
useEffect(() => { | |
clearAfterTimers(); | |
scheduleAfterTransitions(currentState()); | |
return () => { | |
clearAfterTimers(); | |
}; | |
}); | |
const send = (event: TEvent): void => { | |
const sourceStateValue = currentState(); | |
const sourceStateNode = definition.states[sourceStateValue]; | |
if (!sourceStateNode?.on) { | |
return; | |
} | |
const transitionDefRaw = sourceStateNode.on[event.type]; | |
if (!transitionDefRaw) { | |
return; | |
} | |
const transitionDefs = Array.isArray(transitionDefRaw) ? transitionDefRaw : [transitionDefRaw]; | |
let selectedTransition: TransitionDefinitionV2<TState, TContext, TEvent, TActions> | undefined; | |
for (const t of transitionDefs) { | |
if (typeof t === 'string' || !t.guard) { | |
selectedTransition = t; | |
break; | |
} | |
try { | |
if (t.guard(machineContext, event)) { | |
selectedTransition = t; | |
break; | |
} | |
} catch (error) { | |
console.error(`[${definition.id}] Error executing guard for event '${event.type}' in state '${sourceStateValue}':`, error); | |
} | |
} | |
if (!selectedTransition) { | |
return; | |
} | |
let targetState: TState | undefined = undefined; | |
let transitionActions: ActionDefinitionV2<TContext, TEvent, TActions>[] | undefined = undefined; | |
if (typeof selectedTransition === 'string') { | |
targetState = selectedTransition; | |
} else { | |
targetState = selectedTransition.target; | |
transitionActions = selectedTransition.actions; | |
} | |
if (targetState) { | |
const targetStateNode = definition.states[targetState]; | |
if (targetStateNode) { | |
console.log(`[${definition.id}] Transitioning: ${sourceStateValue} --(${event.type})--> ${targetState}`); | |
const actionCtx: ActionContext<TContext, TEvent> = { context: machineContext, event }; | |
executeActions(sourceStateNode.exit, actionsMap, actionCtx, definition.id, 'exit'); | |
executeActions(transitionActions, actionsMap, actionCtx, definition.id, 'transition'); | |
currentState(targetState); | |
executeActions(targetStateNode.entry, actionsMap, actionCtx, definition.id, 'entry'); | |
if (targetState === sourceStateValue) { | |
clearAfterTimers(); | |
scheduleAfterTransitions(targetState); | |
} | |
} else { | |
console.warn(`[${definition.id}] Invalid target state specified for event '${event.type}' in state '${sourceStateValue}': ${targetState}`); | |
} | |
} | |
}; | |
const matches = (...statesToMatch: TState[]): ObservableReadonly<boolean> => { | |
return useMemo(() => statesToMatch.includes(currentState())); | |
}; | |
return { | |
state: useReadonly(currentState), | |
context: machineContext, | |
send, | |
matches, | |
}; | |
} | |
/** | |
* Creates an actor (machine instance) with support for input. | |
*/ | |
function createActor< | |
TState extends string, | |
TContext extends object, | |
TEvent extends { type: string; payload?: any }, | |
TActions extends ActionParamsMap, | |
TInput = void | |
>( | |
definition: MachineDefinitionV2<TState, TContext, TEvent, TActions, TInput>, | |
options?: { input: TInput } | |
): Machine<TState, TContext, TEvent> { | |
return createMachine(definition, options?.input); | |
} | |
// --- Money Machine Definition --- | |
type MoneyState = "idle" | "rich" | "super-rich"; | |
type MoneyEvent = { type: "PAY"; payload?: any }; | |
interface MoneyContext { | |
actualMoney: number; | |
} | |
type MoneyActionParams = { | |
setMoney: { value?: number }; | |
setRich: undefined; | |
setSuperRich: undefined; | |
} | |
const moneyMachineDefinition: MachineDefinitionV2< | |
MoneyState, | |
MoneyContext, | |
MoneyEvent, | |
MoneyActionParams, | |
{ money: number } | |
> = { | |
id: "money", | |
initial: "idle", | |
context: ({ input }: { input: { money: number } }): MoneyContext => ({ | |
actualMoney: Math.min(input.money, 42), | |
}), | |
actions: { | |
setRich: ({ context }: ActionContext<MoneyContext, MoneyEvent>) => { | |
context.actualMoney = 1000000; | |
}, | |
setSuperRich: ({ context }: ActionContext<MoneyContext, MoneyEvent>) => { | |
context.actualMoney = 1000000000; | |
}, | |
setMoney: ( | |
{ context, event }: ActionContext<MoneyContext, MoneyEvent>, | |
params: { value?: number } | undefined | |
) => { | |
let value = params?.value; | |
if (value === undefined && event.payload && typeof event.payload.value === "number") { | |
value = event.payload.value; | |
} | |
if (typeof value === "number") { | |
context.actualMoney += value; | |
} else { | |
context.actualMoney += 1; | |
} | |
} | |
}, | |
states: { | |
idle: { | |
entry: [ | |
{ type: "setMoney", params: { value: 1 } } | |
], | |
on: { | |
PAY: [ | |
{ | |
guard: (ctx: MoneyContext) => ctx.actualMoney >= 1000000, | |
target: "super-rich", | |
actions: ["setSuperRich"] | |
}, | |
{ | |
guard: (ctx: MoneyContext) => ctx.actualMoney >= 42, | |
target: "rich", | |
actions: ["setRich"] | |
}, | |
{ | |
target: "idle", | |
actions: [{ type: "setMoney", params: { value: 2 } }] | |
} | |
] | |
}, | |
}, | |
rich: { | |
on: { | |
PAY: [ | |
{ | |
guard: (ctx: MoneyContext) => ctx.actualMoney >= 1000000, | |
target: "super-rich", | |
actions: ["setSuperRich"] | |
}, | |
{ | |
target: "rich", | |
actions: ["setRich"] | |
} | |
] | |
} | |
}, | |
"super-rich": {} | |
}, | |
}; | |
const moneyActor = createActor(moneyMachineDefinition, { input: { money: 1000 } }); | |
console.log("Money actor context (should be 42):", moneyActor.context.actualMoney); | |
// --- Feedback Machine Definition --- | |
type FeedbackState = 'idle' | 'question' | 'thanks' | 'error'; | |
type FeedbackEvent = | |
| { type: 'START' } | |
| { type: 'feedback.good'; response: string } | |
| { type: 'feedback.bad'; response: string } | |
| { type: 'CLOSE' } | |
| { type: 'SUBMIT' } | |
| { type: 'error'; message: string }; | |
interface FeedbackContext { | |
feedbackGiven?: 'good' | 'bad'; | |
error?: string; | |
} | |
const trackResponse = (response: string) => { | |
console.log(`📊 TRACKING: Response = ${response}`); | |
}; | |
const showConfettiEffect = () => { | |
console.log('🎉 CONFEtti! 🎉'); | |
}; | |
type FeedbackActions = { | |
logStart: undefined; | |
trackResponse: { logResponse?: boolean } | undefined; | |
showConfetti: undefined; | |
showError: { message?: string }; | |
logExitQuestion: undefined; | |
logEntryThanks: undefined; | |
blockSubmitWithoutAnswer: undefined; | |
clearError: undefined; | |
} | |
const feedbackMachineDefinition: MachineDefinitionV2< | |
FeedbackState, | |
FeedbackContext, | |
FeedbackEvent, | |
FeedbackActions | |
> = { | |
id: 'feedback', | |
initial: 'idle', | |
context: {}, | |
actions: { | |
logStart: ({ event }) => console.log(`Action: logStart triggered by ${event.type}`), | |
trackResponse: ( | |
{ context, event }, | |
params | |
) => { | |
const response = event.type === "feedback.good" || event.type === "feedback.bad" ? event.response : undefined; | |
if (response) { | |
trackResponse(response); | |
context.feedbackGiven = response as 'good' | 'bad'; | |
if (params?.logResponse) { | |
console.log(`Logged response: ${response}`); | |
} | |
} else { | |
console.warn('Action: trackResponse called without response in event'); | |
} | |
}, | |
showConfetti: () => { | |
showConfettiEffect(); | |
}, | |
showError: ( | |
{ context, event }, | |
params: { message?: string } = {} | |
) => { | |
let errorMessage = params.message; | |
if (event.type === "error" && event.message) { | |
errorMessage = event.message; | |
} | |
if (!errorMessage) { | |
errorMessage = 'An unknown error occurred.'; | |
} | |
console.error(`Action: showError - ${errorMessage}`); | |
context.error = errorMessage; | |
}, | |
logExitQuestion: () => console.log('Action: Exiting question state...'), | |
logEntryThanks: () => console.log('Action: Entering thanks state...'), | |
blockSubmitWithoutAnswer: ({ context }) => { | |
console.warn('Tried to submit before answer. Please select Good/Bad before submitting.'); | |
context.error = 'Please answer the question before submitting!'; | |
}, | |
clearError: ({ context }) => { | |
context.error = undefined; | |
}, | |
}, | |
states: { | |
idle: { | |
entry: ['clearError'], | |
on: { START: { target: 'question', actions: ['logStart'] } } | |
}, | |
question: { | |
exit: ['logExitQuestion'], | |
on: { | |
'feedback.good': { | |
target: 'thanks', | |
actions: [{ type: 'trackResponse', params: { logResponse: true } }] | |
}, | |
'feedback.bad': { | |
target: 'thanks', | |
actions: ['trackResponse'] | |
}, | |
CLOSE: 'idle', | |
SUBMIT: [ | |
{ | |
guard: (context) => !context.feedbackGiven, | |
target: 'error', | |
actions: [{ type: 'showError', params: { message: 'Submission failed!' } }] | |
}, | |
{ | |
actions: ['blockSubmitWithoutAnswer'] | |
} | |
] | |
} | |
}, | |
thanks: { | |
entry: ['showConfetti', 'logEntryThanks'], | |
on: { CLOSE: 'idle' } | |
}, | |
error: { | |
entry: [{ type: 'showError', params: { message: 'Entered error state.' } }], | |
on: { CLOSE: 'idle' } | |
} | |
} | |
}; | |
const feedbackMachine = createMachine(feedbackMachineDefinition); | |
// --- Feedback Widget --- | |
const FeedbackWidget = (): JSX.Element => { | |
const { state, context, send } = feedbackMachine; | |
return h('div', { | |
style: { | |
border: '2px solid blue', | |
padding: '15px', | |
margin: '10px', | |
fontFamily: 'sans-serif' | |
} | |
}, | |
<> | |
<h3>Feedback Widget</h3> | |
<p> | |
State: <strong>{state}</strong> | |
</p> | |
<p> | |
Context: <pre>{() => JSON.stringify(context, null, 2)}</pre> | |
</p> | |
<Switch when={state}> | |
<Switch.Case when="idle"> | |
<button onClick={() => send({ type: 'START' })}>Provide Feedback</button> | |
</Switch.Case> | |
<Switch.Case when="question"> | |
<h4>How was your experience?</h4> | |
<button onClick={() => send({ type: 'feedback.good', response: 'good' })}>👍 Good</button> | |
<button onClick={() => send({ type: 'feedback.bad', response: 'bad' })}>👎 Bad</button> | |
<hr /> | |
<button onClick={() => send({ type: 'SUBMIT' })}>Simulate Submit Error</button> | |
<button onClick={() => send({ type: 'CLOSE' })}>Close</button> | |
{() => context.error && <p style={{ color: 'red' }}>{context.error}</p>} | |
</Switch.Case> | |
<Switch.Case when="thanks"> | |
<h4>Thank you for your feedback!</h4> | |
<p> | |
You rated: {() => context.feedbackGiven} | |
</p> | |
<button onClick={() => send({ type: 'CLOSE' })}>Close</button> | |
</Switch.Case> | |
<Switch.Case when="error"> | |
<h4 style={{ color: 'red' }}>Error!</h4> | |
<p>{context.error}</p> | |
<button onClick={() => send({ type: 'CLOSE' })}>Close</button> | |
</Switch.Case> | |
<Switch.Default> | |
<p>Unknown state: {state}</p> | |
</Switch.Default> | |
</Switch> | |
</> | |
); | |
}; | |
// --- Money Widget --- | |
const MoneyWidget = (): JSX.Element => { | |
const { state, context, send } = moneyActor; | |
return h('div', { | |
style: { | |
border: '2px solid green', | |
padding: '15px', | |
margin: '10px', | |
fontFamily: 'sans-serif', | |
minWidth: '220px' | |
} | |
}, | |
<> | |
<h3>Money Machine Demo</h3> | |
<p>State: <strong>{state}</strong></p> | |
<p>actualMoney: <strong>{() => context.actualMoney}</strong></p> | |
<button onClick={() => send({ type: 'PAY' })}>PAY</button> | |
</> | |
); | |
}; | |
// --- Timer Machine Definition --- | |
type TimerState = "waitingForButtonPush" | "success" | "timedOut"; | |
type TimerEvent = | |
| { type: "PUSH_BUTTON" } | |
| { type: "RESET" }; | |
interface TimerContext { | |
startedAt: number; | |
timedOutAt?: number; | |
successAt?: number; | |
attempts: number; | |
} | |
const TIMER_DURATION = 1000; | |
type TimerActions = { | |
logSuccess: undefined; | |
logThatYouGotTimedOut: undefined; | |
logReset: undefined; | |
reset: undefined; | |
} | |
const timerMachineDefinition: MachineDefinitionV2< | |
TimerState, | |
TimerContext, | |
TimerEvent, | |
TimerActions | |
> = { | |
id: "timer", | |
initial: "waitingForButtonPush", | |
context: () => ({ | |
startedAt: Date.now(), | |
attempts: 0, | |
}), | |
actions: { | |
logSuccess: ({ context }) => { | |
context.successAt = Date.now(); | |
console.log("[timer] Button pushed in time!"); | |
}, | |
logThatYouGotTimedOut: ({ context }) => { | |
context.timedOutAt = Date.now(); | |
console.log("[timer] Timed out!"); | |
}, | |
logReset: ({ context }) => { | |
context.startedAt = Date.now(); | |
context.timedOutAt = undefined; | |
context.successAt = undefined; | |
console.log("[timer] Reset timer."); | |
}, | |
reset: ({ context }) => { | |
context.startedAt = Date.now(); | |
context.timedOutAt = undefined; | |
context.successAt = undefined; | |
console.log("[timer] Reset action triggered."); | |
} | |
}, | |
delays: { | |
timeout: ({ context }) => TIMER_DURATION, | |
}, | |
states: { | |
waitingForButtonPush: { | |
entry: ["logReset"], | |
after: { | |
[TIMER_DURATION - 1000]: { | |
actions: [() => console.log("[timer] Almost timed out!")], | |
}, | |
timeout: [ | |
{ | |
guard: (context) => context.attempts < 3, | |
actions: [({ context }) => { context.attempts += 1; console.log(`[timer] Attempt #${context.attempts}`); }], | |
target: "waitingForButtonPush", | |
}, | |
{ | |
target: "timedOut", | |
actions: ["logThatYouGotTimedOut"], | |
} | |
] | |
}, | |
on: { | |
PUSH_BUTTON: { | |
actions: ["logSuccess"], | |
target: "success", | |
}, | |
RESET: { | |
actions: ["reset"], | |
target: "waitingForButtonPush", | |
} | |
}, | |
}, | |
success: { | |
entry: [], | |
on: { | |
RESET: { | |
actions: ["reset"], | |
target: "waitingForButtonPush", | |
} | |
} | |
}, | |
timedOut: { | |
entry: [], | |
on: { | |
RESET: { | |
actions: ["reset"], | |
target: "waitingForButtonPush", | |
} | |
} | |
} | |
} | |
}; | |
const timerMachine = createMachine(timerMachineDefinition); | |
// --- Timer Widget --- | |
const TimerWidget = (): JSX.Element => { | |
const { state, context, send } = timerMachine; | |
return ( | |
<div | |
style={{ | |
border: '2px solid orange', | |
padding: '15px', | |
margin: '10px', | |
fontFamily: 'sans-serif', | |
minWidth: '220px' | |
}} | |
> | |
<h3>Timer Machine Demo</h3> | |
<p>State: <strong>{state}</strong></p> | |
<button disabled={state() !== "waitingForButtonPush"} onClick={() => send({ type: "PUSH_BUTTON" })}> | |
Push the Button! | |
</button> | |
<button style={{ marginLeft: "8px" }} onClick={() => send({ type: "RESET" })}> | |
Reset | |
</button> | |
<p>Started at: {() => new Date(context.startedAt).toLocaleTimeString()}</p> | |
<If when={context.successAt}> | |
<p style={{ color: "green" }}> | |
Success at: {new Date(context.successAt ?? 0).toLocaleTimeString()} | |
</p> | |
</If> | |
<If when={context.timedOutAt}> | |
<p style={{ color: "red" }}> | |
Timed out at: {new Date(context.timedOutAt ?? 0).toLocaleTimeString()} | |
</p> | |
</If> | |
</div> | |
); | |
}; | |
// --- Render All Widgets --- | |
render( | |
<div style={{ display: "flex", flexDirection: "row", gap: "4px", alignItems: "flex-start" }}> | |
<FeedbackWidget /> | |
<MoneyWidget /> | |
<TimerWidget /> | |
</div>, | |
document.getElementById("app") | |
); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment