Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,18 +81,30 @@ The `state` consists of three properties: `value`, `nextEvents` and `context`.
The configuration object should contain:

- initial: The initial state node this machine should be in
- debug: Whether or not to log state & context changes. False by default.
- states: Define each of the possible states:

```typescript
const [state, send] = useStateMachine()({
initial: 'inactive',
debug: true,
states: {
inactive: {},
active: {},
},
});
```

### Debugging

If you set `debug: true` in the state machine configuration, it will `console.log` every time:

- A transition is successful (the state machine changed to a different state)
- A transition is unsuccessful (because it was guarded or the state didn't listen to the event that was sent)
- The context changed

If you use a module bundler (Parcel, webpack, rollup, etc), all log messages will be stripped out in the production build.

### Transition Syntax

For each state, you can define the possible transitions.
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const __contextKey = Symbol('CONTEXT');
111 changes: 15 additions & 96 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,105 +1,16 @@
import { useEffect, useReducer, Dispatch, useRef } from 'react';

type Transition<C> =
| string
| {
target: string;
guard?: (context: C) => boolean;
};

type KeysOfTransition<Obj> = Obj extends { on: { [key: string]: Transition<any> } } ? keyof Obj['on'] : never;

interface BaseStateConfig<C> {
on?: {
[key: string]: Transition<C>;
};
}

interface BaseConfig {
initial: string;
states: {
[key: string]: BaseStateConfig<any>;
};
}

type ContextUpdater<C> = (updater: (context: C) => C) => void;

interface MachineStateConfig<C> extends BaseStateConfig<C> {
effect?: (
send: Dispatch<string>,
assign: ContextUpdater<C>
) => void | ((send: Dispatch<string>, assign: ContextUpdater<C>) => void);
}

interface MachineConfig<C> {
initial: string;
states: {
[key: string]: MachineStateConfig<C>;
};
}

const __contextKey = Symbol('CONTEXT');

const getReducer = <
Context extends Record<PropertyKey, any>,
Config extends BaseConfig,
State extends keyof Config['states'],
Event extends KeysOfTransition<Config['states'][keyof Config['states']]>
>(
config: Config
) =>
function reducer(
state: {
value: State;
context: Context;
nextEvents: Event[];
},
event: Event | { type: typeof __contextKey; updater: (context: Context) => Context }
) {
type IndexableState = keyof typeof config.states;
const currentState = config.states[state.value as IndexableState];
const nextState = currentState?.on?.[event as IndexableState];

// Internal action to update context
if (typeof event === 'object' && event.type === __contextKey) {
return {
...state,
context: event.updater(state.context),
};
}

// If there is no defined next state, return early
if (!nextState) return state;

const nextStateValue = typeof nextState === 'string' ? nextState : nextState.target;

// If there are guards, invoke them and return early if the transition is denied
if (typeof nextState === 'object' && nextState.guard && !nextState.guard(state.context)) {
return state;
}

return {
...state,
value: nextStateValue as State,
nextEvents: Object.keys(config.states[nextStateValue].on ?? []) as Event[],
};
};

const useConstant = <T,>(init: () => T) => {
const ref = useRef<T | null>(null);

if (ref.current === null) {
ref.current = init();
}
return ref.current;
};
import { Dispatch, useEffect, useReducer } from 'react';
import { __contextKey } from './constants';
import getReducer from './reducer';
import type { KeysOfTransition, MachineConfig } from './types';
import useConstant from './useConstant';

export default function useStateMachine<Context extends Record<PropertyKey, any>>(context?: Context) {
return function useStateMachineWithContext<Config extends MachineConfig<Context>>(config: Config) {
type IndexableState = keyof typeof config.states;
type State = keyof Config['states'];
type Event = KeysOfTransition<Config['states'][keyof Config['states']]>;


const initialState = useConstant(() => ({
value: config.initial as State,
context: context ?? ({} as Context),
Expand All @@ -108,17 +19,25 @@ export default function useStateMachine<Context extends Record<PropertyKey, any>

const reducer = useConstant(() => getReducer<Context, Config, State, Event>(config));

// The state machine is pretty much just a combination of useReducer and useEffect
// This single reducer contains both the current machine state AND the context.
const [machine, send] = useReducer(reducer, initialState);

// The updater function sends an internal event to the reducer to trigger the actual update
// The updater function sends an internal event to the reducer to trigger the actual context update
// Since whe don't want to expose context updates via `send`, we're using an internal symbol as
// the action type.
// When `send` is exposed to the user, it should just accept transition events.
const update = (updater: (context: Context) => Context) =>
send({
type: __contextKey,
updater,
});

// The effect runs every time a state transition happens.
useEffect(() => {
// Run the "entry" effect and store the returned value in "exit"
const exit = config.states[machine.value as IndexableState]?.effect?.(send as Dispatch<string>, update);
// If the returned value is a function, return it to useEffect (with correct parameter bindings) to be called on cleanup.
return typeof exit === 'function' ? exit.bind(null, send as Dispatch<string>, update) : void 0;
}, [machine.value]);

Expand Down
11 changes: 11 additions & 0 deletions src/log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function log(message: string, ...optionalParams: any[]) {
if (process.env.NODE_ENV === 'development') {
let logArguments = ['%cuseStateMachine ' + `%c${message}`, 'color: #888;', 'color: default;'];

if (optionalParams !== undefined && optionalParams.length > 0) logArguments = [...logArguments, ...optionalParams];

// Console.log clearly accepts parameters other than string, but TypeScript is complaining, so...
// @ts-ignore
console.log.apply(null, logArguments);
}
}
58 changes: 58 additions & 0 deletions src/reducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { __contextKey } from './constants';
import log from './log';
import type { KeysOfTransition, MachineConfig } from './types';

const getReducer = <
Context extends Record<PropertyKey, any>,
Config extends MachineConfig<Context>,
State extends keyof Config['states'],
Event extends KeysOfTransition<Config['states'][keyof Config['states']]>
>(
config: Config
) =>
function reducer(
state: {
value: State;
context: Context;
nextEvents: Event[];
},
event: Event | { type: typeof __contextKey; updater: (context: Context) => Context }
) {
type IndexableState = keyof typeof config.states;
const currentState = config.states[state.value as IndexableState];
const nextState = currentState?.on?.[event as IndexableState];

// Internal action to update context
if (typeof event === 'object' && event.type === __contextKey) {
const nextContext = event.updater(state.context);
if (config.debug) log('Context update from %o to %o', state.context, nextContext);
return {
...state,
context: nextContext,
};
}

// If there is no defined next state, return early
if (!nextState) {
if (config.debug) log(`Current state %o doesn't listen to event ${event}.`, state);
return state;
}

const nextStateValue = typeof nextState === 'string' ? nextState : nextState.target;

// If there are guards, invoke them and return early if the transition is denied
if (typeof nextState === 'object' && nextState.guard && !nextState.guard(state.context)) {
if (config.debug) log(`Transition from ${state.value} to ${nextStateValue} denied by guard`);
return state;
}

if (config.debug) log(`Transition from ${state.value} to ${nextStateValue}`);

return {
...state,
value: nextStateValue as State,
nextEvents: Object.keys(config.states[nextStateValue].on ?? []) as Event[],
};
};

export default getReducer;
35 changes: 35 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Dispatch } from 'react';

export type Transition<C> =
| string
| {
target: string;
guard?: (context: C) => boolean;
};

export type KeysOfTransition<Obj> = Obj extends { on: { [key: string]: Transition<any> } } ? keyof Obj['on'] : never;

export interface BaseStateConfig<C> {
on?: {
[key: string]: Transition<C>;
};
}

export type ContextUpdater<C> = (updater: (context: C) => C) => void;

export interface MachineStateConfig<C> extends BaseStateConfig<C> {
effect?: (
send: Dispatch<string>,
assign: ContextUpdater<C>
) => void | ((send: Dispatch<string>, assign: ContextUpdater<C>) => void);
}

export interface MachineConfig<C> {
initial: string;
debug?: boolean;
states: {
[key: string]: MachineStateConfig<C>;
};
}


12 changes: 12 additions & 0 deletions src/useConstant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useRef } from 'react';

const useConstant = <T,>(init: () => T) => {
const ref = useRef<T | null>(null);

if (ref.current === null) {
ref.current = init();
}
return ref.current;
};

export default useConstant;