@xstate/immer


XState Immer
XState with Immer

The @xstate/immer package contains utilities for using Immer with XState.

Quick Start

Included in @xstate/immer:

  • assign() - an Immer action that allows you to immutably assign to machine context in a convenient way
  • createUpdater() - a useful function that allows you to cohesively define a context update event event creator and assign action, all together. (See an example below)
  1. Install immer, xstate, @xstate/immer:
  1. npm install immer xstate @xstate/immer

Note: You don’t need to import anything from immer; it is a peer-dependency of @xstate/immer, so it must be installed.

  1. Import the Immer utilities:
  1. import { createMachine, interpret } from 'xstate';
  2. import { assign, createUpdater } from '@xstate/immer';
  3. const levelUpdater = createUpdater('UPDATE_LEVEL', (ctx, { input }) => {
  4. ctx.level = input;
  5. });
  6. const toggleMachine = createMachine({
  7. id: 'toggle',
  8. context: {
  9. count: 0,
  10. level: 0
  11. },
  12. initial: 'inactive',
  13. states: {
  14. inactive: {
  15. on: {
  16. TOGGLE: {
  17. target: 'active',
  18. // Immutably update context the same "mutable"
  19. // way as you would do with Immer!
  20. actions: assign((ctx) => ctx.count++)
  21. }
  22. }
  23. },
  24. active: {
  25. on: {
  26. TOGGLE: {
  27. target: 'inactive'
  28. },
  29. // Use the updater for more convenience:
  30. [levelUpdater.type]: {
  31. actions: levelUpdater.action
  32. }
  33. }
  34. }
  35. }
  36. });
  37. const toggleService = interpret(toggleMachine)
  38. .onTransition((state) => {
  39. console.log(state.context);
  40. })
  41. .start();
  42. toggleService.send('TOGGLE');
  43. // { count: 1, level: 0 }
  44. toggleService.send(levelUpdater.update(9));
  45. // { count: 1, level: 9 }
  46. toggleService.send('TOGGLE');
  47. // { count: 2, level: 9 }
  48. toggleService.send(levelUpdater.update(-100));
  49. // Notice how the level is not updated in 'inactive' state:
  50. // { count: 2, level: 9 }

API

assign(recipe)

Returns an XState event object that will update the machine’s context to reflect the changes (“mutations”) to context made in the recipe function.

The recipe is similar to the function that you would pass to Immer’s produce(val, recipe) function), with the addition that you get the same arguments as a normal XState assigner passed to assign(assigner) (context, event, meta).

Arguments for assign:

Argument Type Description
recipe function A function where “mutations” to context are made. See the Immer docs.

Arguments for recipe:

Argument Type Description
context any The context data of the current state
event event object The received event object
meta assign meta object An object containing meta data such as the state, SCXML _event, etc.
  1. import { createMachine } from 'xstate';
  2. import { assign } from '@xstate/immer';
  3. const userMachine = createMachine({
  4. id: 'user',
  5. context: {
  6. name: null,
  7. address: {
  8. city: null,
  9. state: null,
  10. country: null
  11. }
  12. },
  13. initial: 'active',
  14. states: {
  15. active: {
  16. on: {
  17. CHANGE_COUNTRY: {
  18. actions: assign((context, event) => {
  19. context.address.country = event.value;
  20. })
  21. }
  22. }
  23. }
  24. }
  25. });
  26. const { initialState } = userMachine;
  27. const nextState = userMachine.transition(initialState, {
  28. type: 'UPDATE_COUNTRY',
  29. country: 'USA'
  30. });
  31. nextState.context.address.country;
  32. // => 'USA'

createUpdater(eventType, recipe)

Returns an object that is useful for creating context updaters.

Argument Type Description
eventType string The event type for the Immer update event
recipe function A function that takes in the context and an Immer update event object to “mutate” the context

An Immer update event object is an object that contains:

  • type: the eventType specified
  • input: the “payload” of the update event

The object returned by createUpdater(...) is an updater object containing:

  • type: the eventType passed into createUpdater(eventType, ...). This is used for specifying transitions in which the update will occur.
  • action: the assign action object that will update the context.
  • update: the event creator that takes in the input and returns an event object with the specified eventType and input that will be passed to recipe(context, event).

⚠️ Note: The .update(...) event creator is pure; it only returns an assign action object, and doesn’t directly update context.

  1. import { createMachine } from 'xstate';
  2. import { createUpdater } from '@xstate/immer';
  3. // The second argument is an Immer update event that looks like:
  4. // {
  5. // type: 'UPDATE_NAME',
  6. // input: 'David' // or any string
  7. // }
  8. const nameUpdater = createUpdater('UPDATE_NAME', (context, { input }) => {
  9. context.name = input;
  10. });
  11. const ageUpdater = createUpdater('UPDATE_AGE', (context, { input }) => {
  12. context.age = input;
  13. });
  14. const formMachine = createMachine({
  15. initial: 'editing',
  16. context: {
  17. name: '',
  18. age: null
  19. },
  20. states: {
  21. editing: {
  22. on: {
  23. // The updater.type can be used directly for transitions
  24. // where the updater.action function will be applied
  25. [nameUpdater.type]: { actions: nameUpdater.action },
  26. [ageUpdater.type]: { actions: ageUpdater.action }
  27. }
  28. }
  29. }
  30. });
  31. const service = interpret(formMachine)
  32. .onTransition((state) => {
  33. console.log(state.context);
  34. })
  35. .start();
  36. // The event object sent will look like:
  37. // {
  38. // type: 'UPDATE_NAME',
  39. // input: 'David'
  40. // }
  41. service.send(nameUpdater.update('David'));
  42. // => { name: 'David', age: null }
  43. // The event object sent will look like:
  44. // {
  45. // type: 'UPDATE_AGE',
  46. // input: 100
  47. // }
  48. service.send(ageUpdater.update(100));
  49. // => { name: 'David', age: 100 }

TypeScript

To properly type the Immer assign action creator, pass in the context and event types as generic types:

  1. interface SomeContext {
  2. name: string;
  3. }
  4. interface SomeEvent {
  5. type: 'SOME_EVENT';
  6. value: string;
  7. }
  8. // ...
  9. {
  10. actions: assign<SomeContext, SomeEvent>((context, event) => {
  11. context.name = event.value;
  12. // ... etc.
  13. });
  14. }

To properly type createUpdater, pass in the context and the specific ImmerUpdateEvent<...> (see below) types as generic types:

  1. import { createUpdater, ImmerUpdateEvent } from '@xstate/immer';
  2. // This is the same as:
  3. // {
  4. // type: 'UPDATE_NAME';
  5. // input: string;
  6. // }
  7. type NameUpdateEvent = ImmerUpdateEvent<'UPDATE_NAME', string>;
  8. const nameUpdater = createUpdater<SomeContext, NameUpdateEvent>(
  9. 'UPDATE_NAME',
  10. (ctx, { input }) => {
  11. ctx.name = input;
  12. }
  13. );
  14. // You should use NameUpdateEvent directly as part of the event type
  15. // in createMachine<SomeContext, SomeEvent>.

Here is a fully typed example of the previous form example:

  1. import { createMachine } from 'xstate';
  2. import { createUpdater, ImmerUpdateEvent } from '@xstate/immer';
  3. interface FormContext {
  4. name: string;
  5. age: number | undefined;
  6. }
  7. type NameUpdateEvent = ImmerUpdateEvent<'UPDATE_NAME', string>;
  8. type AgeUpdateEvent = ImmerUpdateEvent<'UPDATE_AGE', number>;
  9. const nameUpdater = createUpdater<FormContext, NameUpdateEvent>(
  10. 'UPDATE_NAME',
  11. (ctx, { input }) => {
  12. ctx.name = input;
  13. }
  14. );
  15. const ageUpdater = createUpdater<FormContext, AgeUpdateEvent>(
  16. 'UPDATE_AGE',
  17. (ctx, { input }) => {
  18. ctx.age = input;
  19. }
  20. );
  21. type FormEvent =
  22. | NameUpdateEvent
  23. | AgeUpdateEvent
  24. | {
  25. type: 'SUBMIT';
  26. };
  27. const formMachine = createMachine<FormContext, FormEvent>({
  28. initial: 'editing',
  29. context: {
  30. name: '',
  31. age: undefined
  32. },
  33. states: {
  34. editing: {
  35. on: {
  36. [nameUpdater.type]: { actions: nameUpdater.action },
  37. [ageUpdater.type]: { actions: ageUpdater.action },
  38. SUBMIT: 'submitting'
  39. }
  40. },
  41. submitting: {
  42. // ...
  43. }
  44. }
  45. });