# Context
While finite states are well-defined in finite state machines and statecharts, state that represents quantitative data (e.g., arbitrary strings, numbers, objects, etc.) that can be potentially infinite is represented as extended state (opens new window) instead. This makes statecharts much more useful for real-life applications.
In XState, extended state is known as context. Below is an example of how context
is used to simulate filling a glass of water:
import { createMachine, assign } from 'xstate';
// Action to increment the context amount
const addWater = assign({
amount: (context, event) => context.amount + 1
});
// Guard to check if the glass is full
function glassIsFull(context, event) {
return context.amount >= 10;
}
const glassMachine = createMachine(
{
id: 'glass',
// the initial context (extended state) of the statechart
context: {
amount: 0
},
initial: 'empty',
states: {
empty: {
on: {
FILL: {
target: 'filling',
actions: 'addWater'
}
}
},
filling: {
// Transient transition
always: {
target: 'full',
cond: 'glassIsFull'
},
on: {
FILL: {
target: 'filling',
actions: 'addWater'
}
}
},
full: {}
}
},
{
actions: { addWater },
guards: { glassIsFull }
}
);
The current context is referenced on the State
as state.context
:
const nextState = glassMachine.transition(glassMachine.initialState, {
type: 'FILL'
});
nextState.context;
// => { amount: 1 }
# Initial Context
The initial context is specified on the context
property of the Machine
:
const counterMachine = createMachine({
id: 'counter',
// initial context
context: {
count: 0,
message: 'Currently empty',
user: {
name: 'David'
},
allowedToIncrement: true
// ... etc.
},
states: {
// ...
}
});
For dynamic context
(that is, context
whose initial value is retrieved or provided externally), you can use a machine factory function that creates the machine with the provided context values (implementation may vary):
const createCounterMachine = (count, time) => {
return createMachine({
id: 'counter',
// values provided from function arguments
context: {
count,
time
}
// ...
});
};
const counterMachine = createCounterMachine(42, Date.now());
Or for existing machines, machine.withContext(...)
should be used:
const counterMachine = createMachine({
/* ... */
});
// retrieved dynamically
const someContext = { count: 42, time: Date.now() };
const dynamicCounterMachine = counterMachine.withContext(someContext);
The initial context of a machine can be retrieved from its initial state:
dynamicCounterMachine.initialState.context;
// => { count: 42, time: 1543687816981 }
This is preferred to accessing machine.context
directly, since the initial state is computed with initial assign(...)
actions and transient transitions, if any.
# Assign Action
The assign()
action is used to update the machine's context
. It takes the context "assigner", which represents how values in the current context should be assigned.
Argument | Type | Description |
---|---|---|
assigner | object or function | The object assigner or function assigner which assigns values to the context (see below) |
The "assigner" can be an object (recommended):
import { createMachine, assign } from 'xstate';
// example: property assigner
// ...
actions: assign({
// increment the current count by the event value
count: (context, event) => context.count + event.value,
// assign static value to the message (no function needed)
message: 'Count changed'
}),
// ...
Or it can be a function that returns the updated state:
// example: context assigner
// ...
// return a partial (or full) updated context
actions: assign((context, event) => {
return {
count: context.count + event.value,
message: 'Count changed'
}
}),
// ...
Both the property assigner and context assigner function signatures above are given 3 arguments: the context
, event
, and meta
:
Argument | Type | Description |
---|---|---|
context | TContext | The current context (extended state) of the machine |
event | EventObject | The event that triggered the assign action |
meta 4.7+ | AssignMeta | an object with meta data (see below) |
The meta
object contains:
state
- the current state in a normal transition (undefined
for the initial state transition)action
- the assign action
WARNING
The assign(...)
function is an action creator; it is a pure function that only returns an action object and does not imperatively make assignments to the context.
# Action Order
Custom actions are always executed with regard to the next state in the transition. When a state transition has assign(...)
actions, those actions are always batched and computed first, to determine the next state. This is because a state is a combination of the finite state and the extended state (context).
For example, in this counter machine, the custom actions will not work as expected:
const counterMachine = createMachine({
id: 'counter',
context: { count: 0 },
initial: 'active',
states: {
active: {
on: {
INC_TWICE: {
actions: [
(context) => console.log(`Before: ${context.count}`),
assign({ count: (context) => context.count + 1 }), // count === 1
assign({ count: (context) => context.count + 1 }), // count === 2
(context) => console.log(`After: ${context.count}`)
]
}
}
}
}
});
interpret(counterMachine).start().send({ type: 'INC_TWICE' });
// => "Before: 2"
// => "After: 2"
This is because both assign(...)
actions are batched in order and executed first (in the microstep), so the next state context
is { count: 2 }
, which is passed to both custom actions. Another way of thinking about this transition is reading it like:
When in the
active
state and theINC_TWICE
event occurs, the next state is theactive
state withcontext.count
updated, and then these custom actions are executed on that state.
A good way to refactor this to get the desired result is modeling the context
with explicit previous values, if those are needed:
const counterMachine = createMachine({
id: 'counter',
context: { count: 0, prevCount: undefined },
initial: 'active',
states: {
active: {
on: {
INC_TWICE: {
actions: [
(context) => console.log(`Before: ${context.prevCount}`),
assign({
count: (context) => context.count + 1,
prevCount: (context) => context.count
}), // count === 1, prevCount === 0
assign({ count: (context) => context.count + 1 }), // count === 2
(context) => console.log(`After: ${context.count}`)
]
}
}
}
}
});
interpret(counterMachine).start().send({ type: 'INC_TWICE' });
// => "Before: 0"
// => "After: 2"
The benefits of this are:
- The extended state (context) is modeled more explicitly
- There are no implicit intermediate states, preventing hard-to-catch bugs
- The action order is more independent (the "Before" log can even go after the "After" log!)
- Facilitates testing and examining the state
# Notes
- 🚫 Never mutate the machine's
context
externally. Everything happens for a reason, and every context change should happen explicitly due to an event. - Prefer the object syntax of
assign({ ... })
. This makes it possible for future analysis tools to predict how certain properties can change declaratively. - Assignments can be stacked, and will run sequentially:
// ...
actions: [
assign({ count: 3 }), // context.count === 3
assign({ count: context => context.count * 2 }) // context.count === 6
],
// ...
- Just like with
actions
, it's best to representassign()
actions as strings or functions, and then reference them in the machine options:
const countMachine = createMachine({
initial: 'start',
context: { count: 0 }
states: {
start: {
entry: 'increment'
}
}
}, {
actions: {
increment: assign({ count: context => context.count + 1 }),
decrement: assign({ count: context => context.count - 1 })
}
});
Or as named functions (same result as above):
const increment = assign({ count: context => context.count + 1 });
const decrement = assign({ count: context => context.count - 1 });
const countMachine = createMachine({
initial: 'start',
context: { count: 0 }
states: {
start: {
// Named function
entry: increment
}
}
});
- Ideally, the
context
should be representable as a plain JavaScript object; i.e., it should be serializable as JSON. - Since
assign()
actions are raised, the context is updated before other actions are executed. This means that other actions within the same step will get the updatedcontext
rather than what it was before theassign()
action was executed. You shouldn't rely on action order for your states, but keep this in mind. See action order for more details.
# TypeScript
For proper type inference, add the context type as the first type parameter to createMachine<TContext, ...>
:
interface CounterContext {
count: number;
user?: {
name: string;
};
}
const machine = createMachine<CounterContext>({
// ...
context: {
count: 0,
user: undefined
}
// ...
});
When applicable, you can also use typeof ...
as a shorthand:
const context = {
count: 0,
user: { name: '' }
};
const machine = createMachine<typeof context>({
// ...
context
// ...
});
In most cases, the types for context
and event
in assign(...)
actions will be automatically inferred from the type parameters passed into createMachine<TContext, TEvent>
:
interface CounterContext {
count: number;
}
const machine = createMachine<CounterContext>({
// ...
context: {
count: 0
},
// ...
{
on: {
INCREMENT: {
// Inferred automatically in most cases
actions: assign({
count: (context) => {
// context: { count: number }
return context.count + 1;
}
})
}
}
}
});
However, TypeScript inference isn't perfect, so the responsible thing to do is to add the context and event as generics into assign<Context, Event>(...)
:
// ...
on: {
INCREMENT: {
// Generics guarantee proper inference
actions: assign<CounterContext, CounterEvent>({
count: (context) => {
// context: { count: number }
return context.count + 1;
}
});
}
}
// ...
# Quick Reference
Set initial context
const machine = createMachine({
// ...
context: {
count: 0,
user: undefined
// ...
}
});
Set dynamic initial context
const createSomeMachine = (count, user) => {
return createMachine({
// ...
// Provided from arguments; your implementation may vary
context: {
count,
user
// ...
}
});
};
Set custom initial context
const machine = createMachine({
// ...
// Provided from arguments; your implementation may vary
context: {
count: 0,
user: undefined
// ...
}
});
const myMachine = machine.withContext({
count: 10,
user: {
name: 'David'
}
});
Assign to context
const machine = createMachine({
// ...
context: {
count: 0,
user: undefined
// ...
},
// ...
on: {
INCREMENT: {
actions: assign({
count: (context, event) => context.count + 1
})
}
}
});
Assignment (static)
// ...
actions: assign({
counter: 42
}),
// ...
Assignment (property)
// ...
actions: assign({
counter: (context, event) => {
return context.count + event.value;
}
}),
// ...
Assignment (context)
// ...
actions: assign((context, event) => {
return {
counter: context.count + event.value,
time: event.time,
// ...
}
}),
// ...
Assignment (multiple)
// ...
// assume context.count === 1
actions: [
// assigns context.count to 1 + 1 = 2
assign({ count: (context) => context.count + 1 }),
// assigns context.count to 2 * 3 = 6
assign({ count: (context) => context.count * 3 })
],
// ...