Skip to content
Merged
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
29 changes: 16 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ const [state, send] = useStateMachine()({
states: {
active: {
on: { TOGGLE: 'inactive' },
effect(send, update, event) {
effect({ send, setContext, event, context }) {
console.log('Just entered the Active state');
return () => console.log('Just Left the Active state');
},
Expand All @@ -147,11 +147,12 @@ const [state, send] = useStateMachine()({
});
```

The effect function receives three params:
The effect function receives an object as parameter with four keys:

- `send`: Takes an event as argument, provided in shorthand string format (e.g. "TOGGLE") or as an event object (e.g. `{ type: "TOGGLE" }`)
- `update`: Takes an updater function as parameter to update the context (more on context below). Returns an object with `send`, so you can update the context and send an event on a single line.
- `setContext`: Takes an updater function as parameter to set a new context (more on context below). Returns an object with `send`, so you can set the context and send an event on a single line.
- `event`: The event that triggered a transition to this state. (The event parameter always uses the object format (e.g. `{ type: 'TOGGLE' }`).).
- `context` The context at the time the effect runs.

In this example, the state machine will always send the "RETRY" event when entering the error state:

Expand All @@ -164,7 +165,7 @@ const [state, send] = useStateMachine()({
on: {
RETRY: 'load',
},
effect(send) {
effect({ send }) {
send('RETRY');
},
},
Expand All @@ -184,7 +185,7 @@ const [state, send] = useStateMachine()({
on: {
TOGGLE: {
target: 'active',
guard(context, event) {
guard({ context, event }) {
// Return a boolean to allow or block the transition
},
},
Expand All @@ -197,25 +198,25 @@ const [state, send] = useStateMachine()({
});
```

The guard function receives the current context and the event as arguments. The event parameter always uses the object format (e.g. `{ type: 'TOGGLE' }`).
The guard function receives an object with the current context and the event. The event parameter always uses the object format (e.g. `{ type: 'TOGGLE' }`).

### Extended state (context)

Besides the finite number of states, the state machine can have extended state (known as context).

You can provide the initial context value as the first argument to the State Machine hook, and use the update function within your effects to change the context:
You can provide the initial context value as the first argument to the State Machine hook, and use the `setContext` function within your effects to change the context:

```js
const [state, send] = useStateMachine({ toggleCount: 0 })({
initial: 'idle',
initial: 'inactive',
states: {
inactive: {
on: { TOGGLE: 'active' },
},
active: {
on: { TOGGLE: 'inactive' },
effect(send, update, event) {
update(context => ({ toggleCount: context.toggleCount + 1 }));
effect({ setContext }) {
setContext(context => ({ toggleCount: context.toggleCount + 1 }));
},
},
},
Expand All @@ -228,19 +229,21 @@ send('TOGGLE');
console.log(state); // { context: { toggleCount: 1 }, value: 'active', nextEvents: ['TOGGLE'] }
```

#### Context Typing

The context types are inferred automatically in TypeScript, but you can provide you own typing if you want to be more specific:

```typescript
const [state, send] = useStateMachine<{ toggleCount: number }>({ toggleCount: 0 })({
initial: 'idle',
initial: 'inactive',
states: {
inactive: {
on: { TOGGLE: 'active' },
},
active: {
on: { TOGGLE: 'inactive' },
effect(send, update, event) {
update(context => ({ toggleCount: context.toggleCount + 1 }));
effect({ setContext }) {
setContext(context => ({ toggleCount: context.toggleCount + 1 }));
},
},
},
Expand Down
39 changes: 24 additions & 15 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type Transition<Context, Events, State extends string, EventString extends strin
/**
* A guard function runs before the transition: If the guard returns false the transition will be denied.
*/
guard?: (context: Context, event: Event<Events, EventString>) => boolean;
guard?: (params: { context: Context; event: Event<Events, EventString> }) => boolean;
};

type ContextUpdater<Context> = (context: Context) => Context;
Expand All @@ -32,17 +32,19 @@ interface MachineStateConfig<Context, Events, State extends string, EventString
* Effects are triggered when the state machine enters a given state. If you return a function from your effect,
* it will be invoked when leaving that state (similarly to how useEffect works in React).
*/
effect?: (
send: Dispatch<SendEvent<Events, EventString>>,
assign: (updater?: ContextUpdater<Context>) => { send: Dispatch<SendEvent<Events, EventString>> },
event?: Event<Events, EventString>
) =>
effect?: (params: {
send: Dispatch<SendEvent<Events, EventString>>;
setContext: (updater?: ContextUpdater<Context>) => { send: Dispatch<SendEvent<Events, EventString>> };
event?: Event<Events, EventString>;
context: Context;
}) =>
| void
| ((
send: Dispatch<SendEvent<Events, EventString>>,
assign: (updater?: ContextUpdater<Context>) => { send: Dispatch<SendEvent<Events, EventString>> },
event?: Event<Events, EventString>
) => void);
| ((params: {
send: Dispatch<SendEvent<Events, EventString>>;
setContext: (updater?: ContextUpdater<Context>) => { send: Dispatch<SendEvent<Events, EventString>> };
event?: Event<Events, EventString>;
context: Context;
}) => void);
}

interface MachineConfig<Context, Events, State extends string, EventString extends string> {
Expand Down Expand Up @@ -168,7 +170,7 @@ function getReducer<Context, Events, State extends string, EventString extends s
} else {
target = nextState.target;
// If there are guards, invoke them and return early if the transition is denied
if (nextState.guard && !nextState.guard(state.context, eventObject)) {
if (nextState.guard && !nextState.guard({ context: state.context, event: eventObject })) {
if (config.verbose)
log(
`Transition from "${state.value}" to "${target}" denied by guard`,
Expand Down Expand Up @@ -220,7 +222,7 @@ function useStateMachineImpl<Context, Events>(context: Context): UseStateMachine
);

// The updater function sends an internal event to the reducer to trigger the actual update
const update = (updater?: ContextUpdater<Context>) => {
const setContext = (updater?: ContextUpdater<Context>) => {
if (updater) {
dispatch({
type: DispatchType.Update,
Expand All @@ -231,8 +233,15 @@ function useStateMachineImpl<Context, Events>(context: Context): UseStateMachine
};

useEffect(() => {
const exit = config.states[machine.value]?.effect?.(send, update, machine.event);
return typeof exit === 'function' ? () => exit(send, update, machine.event) : undefined;
const exit = config.states[machine.value]?.effect?.({
send,
setContext,
event: machine.event,
context: machine.context,
});
return typeof exit === 'function'
? () => exit({ send, setContext, event: machine.event, context: machine.context })
: undefined;
// We are bypassing the linter here because we deliberately want the effects to run:
// - When the machine state changes or
// - When a different event was sent (e.g. self-transition)
Expand Down
6 changes: 3 additions & 3 deletions test-d/useStateMachine.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ const machine2 = useStateMachine<{ time: number }>({ time: 0 })({
target: 'running',
},
},
effect(send, update) {
effect({send, setContext}) {
expectAssignable<Dispatch<'START' | 'PAUSE' | 'RESET' | { type: 'START' } | { type: 'PAUSE' } | { type: 'RESET' }>>(send);
expectAssignable<(value: (context: { time: number }) => { time: number }) => void>(update);
expectAssignable<(value: (context: { time: number }) => { time: number }) => void>(setContext);
},
},
running: {
Expand All @@ -69,7 +69,7 @@ const machine2 = useStateMachine<{ time: number }>({ time: 0 })({
RESET: 'idle',
START: {
target: 'running',
guard(context, event) {
guard({context, event}) {
expectType<{ time: number }>(context)
expectType<{ type: "START" | "PAUSE" | "RESET"; [key: string]: any; }>(event)
return true;
Expand Down
46 changes: 38 additions & 8 deletions test/useStateMachine.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ describe('useStateMachine', () => {
states: {
inactive: {
on: { TOGGLE: 'active' },
effect(send) {
effect({ send }) {
send('TOGGLE');
},
},
Expand Down Expand Up @@ -243,7 +243,7 @@ describe('useStateMachine', () => {
act(() => {
result.current[1]({ type: 'ACTIVATE', number: 10 });
});
expect(effect.mock.calls[0][2]).toStrictEqual({ type: 'ACTIVATE', number: 10 });
expect(effect.mock.calls[0][0]['event']).toStrictEqual({ type: 'ACTIVATE', number: 10 });
});
});
describe('guarded transitions', () => {
Expand Down Expand Up @@ -342,6 +342,36 @@ describe('useStateMachine', () => {
nextEvents: ['TOGGLE'],
});
});

it('should get the context inside effects', () => {
const { result } = renderHook(() =>
useStateMachine<{ foo: string }>({ foo: 'bar' })({
initial: 'inactive',
states: {
inactive: {
on: { TOGGLE: 'active' },
effect(params) {
expect(params.context).toStrictEqual({
foo: 'bar',
});
expect(params.event).toBeUndefined();
},
},
active: {
on: { TOGGLE: 'inactive' },
},
},
})
);

expect(result.current[0]).toStrictEqual({
value: 'inactive',
context: { foo: 'bar' },
event: undefined,
nextEvents: ['TOGGLE'],
});
});

it('should update context on entry', () => {
const { result } = renderHook(() =>
useStateMachine<{ toggleCount: number }>({ toggleCount: 0 })({
Expand All @@ -352,8 +382,8 @@ describe('useStateMachine', () => {
},
active: {
on: { TOGGLE: 'inactive' },
effect(_, update) {
update((context) => ({ toggleCount: context.toggleCount + 1 }));
effect({ setContext }) {
setContext(c => ({ toggleCount: c.toggleCount + 1 }));
},
},
},
Expand All @@ -380,8 +410,8 @@ describe('useStateMachine', () => {
states: {
inactive: {
on: { TOGGLE: 'active' },
effect(_, update) {
return () => update((context) => ({ toggleCount: context.toggleCount + 1 }));
effect({ setContext }) {
return () => setContext(c => ({ toggleCount: c.toggleCount + 1 }));
},
},
active: {
Expand Down Expand Up @@ -413,7 +443,7 @@ describe('useStateMachine', () => {
initial: 'idle',
states: {
idle: {
effect: (send) => send('invalid'),
effect: ({ send }) => send('invalid'),
},
},
})
Expand All @@ -430,7 +460,7 @@ describe('useStateMachine', () => {
initial: 'idle',
states: {
idle: {
effect: (send) => send({ type: 'invalid' }),
effect: ({ send }) => send({ type: 'invalid' }),
},
},
})
Expand Down