Sviatoslav Oleksiv

Using Context and Handling Side Effects in XState

In the previous lesson, we created a basic state machine with simple states and transitions. Now, we will explore two important features of XState: context and side effects. Context allows us to store additional data within a state machine, while side effects enable us to handle external operations, such as making API calls, during state transitions.

In this lesson, you will learn:

What is Context in XState?

In XState, context is used to hold extra information or data that isn't part of the finite states. While states define the condition of the system, context stores data like counters, form values, or any dynamic data that might change over time.

Example: Counter Machine with Context

Let's create a state machine that uses context to manage a counter. The counter starts at zero and increments by one every time a button is clicked.

import { createMachine, assign } from 'xstate';

const counterMachine = createMachine({
  id: 'counter',
  initial: 'active',
  context: {
    count: 0  // Initial context value
  },
  states: {
    active: {
      on: {
        INCREMENT: {
          actions: 'incrementCount'  // Action to increment the count
        }
      }
    }
  }
}, {
  actions: {
    incrementCount: assign({
      count: (context) => context.count + 1  // Updates the context count
    })
  }
});

export default counterMachine;

Explanation:

Integrating Context in a React Component

Now, let's integrate this machine into a React component and display the counter.

import React from 'react';
import { useMachine } from '@xstate/react';
import counterMachine from './counterMachine';

const Counter = () => {
  const [state, send] = useMachine(counterMachine);  // useMachine hook to use the state machine

  return (
    <div>
      <p>Current Count: {state.context.count}</p>  {/* Displays the count from context */}
      <button onClick={() => send('INCREMENT')}>  {/* Sends the INCREMENT event */}
        Increment
      </button>
    </div>
  );
};

export default Counter;

Explanation:

Handling Side Effects in XState

In addition to managing state, applications often need to handle side effects. Side effects are external operations such as API requests, logging, or timers that occur during state transitions. XState provides two main ways to handle side effects:

  1. Actions: Perform side effects synchronously during state transitions.
  2. Services: Handle asynchronous operations like API calls or timers.

Example: Logging Side Effects with Actions

Let's add a side effect that logs a message to the console every time the counter is incremented.

import { createMachine, assign } from 'xstate';

const counterMachine = createMachine({
  id: 'counter',
  initial: 'active',
  context: {
    count: 0
  },
  states: {
    active: {
      on: {
        INCREMENT: {
          actions: ['incrementCount', 'logCount']  // Executes both actions on INCREMENT
        }
      }
    }
  }
}, {
  actions: {
    incrementCount: assign({
      count: (context) => context.count + 1
    }),
    logCount: (context) => console.log(`Count is now ${context.count + 1}`)  // Logs the new count
  }
});

export default counterMachine;

Explanation:

Handling Asynchronous Side Effects with Services

For asynchronous operations, such as making API calls, XState uses services. Services are functions that run during state transitions and return promises to handle asynchronous actions.

Example: Fetching Data with Services

Let’s modify our state machine to simulate fetching data from an API when the counter is incremented. The machine will go into a loading state while the data is fetched.

import { createMachine, assign } from 'xstate';

const fetchMachine = createMachine({
  id: 'fetch',
  initial: 'idle',
  context: {
    data: null,
    error: null
  },
  states: {
    idle: {
      on: { FETCH: 'loading' }
    },
    loading: {
      invoke: {
        src: 'fetchData',  // The service that fetches data
        onDone: {
          target: 'success',
          actions: assign({ data: (context, event) => event.data })  // Stores fetched data in context
        },
        onError: {
          target: 'failure',
          actions: assign({ error: (context, event) => event.data })  // Stores error in context
        }
      }
    },
    success: {
      type: 'final'
    },
    failure: {
      on: {
        RETRY: 'loading'  // Retry fetching data on error
      }
    }
  }
}, {
  services: {
    fetchData: () => fetch('https://jsonplaceholder.typicode.com/posts/1')
      .then(response => response.json())
  }
});

export default fetchMachine;

Explanation:

Integrating the Fetch Machine in React

Here’s how to integrate the machine with React to fetch data when a button is clicked.

import React from 'react';
import { useMachine } from '@xstate/react';
import fetchMachine from './fetchMachine';

const FetchData = () => {
  const [state, send] = useMachine(fetchMachine);

  return (
    <div>
      {state.matches('idle') && <button onClick={() => send('FETCH')}>Fetch Data</button>}
      {state.matches('loading') && <p>Loading...</p>}
      {state.matches('success') && <p>Data: {JSON.stringify(state.context.data)}</p>}
      {state.matches('failure') && <div>
        <p>Error: {state.context.error}</p>
        <button onClick={() => send('RETRY')}>Retry</button>
      </div>}
    </div>
  );
};

export default FetchData;

Explanation:

Conclusion

In this lesson, we learned how to use context in XState to manage additional data and how to handle both synchronous and asynchronous side effects. We explored how actions can trigger side effects during state transitions and how services can be used for asynchronous operations like fetching data from an API.

In the next lesson, we’ll explore advanced state management techniques using hierarchical and parallel states in XState.


Next Lesson: Advanced Concepts: Hierarchical and Parallel States