State Machines in React: Using XState for Complex UI Logic

State Machines in React: Using XState for Complex UI Logic

Photo by Fahrul Razi on Unsplash

React applications grow in complexity, managing state becomes increasingly challenging. Traditional state management solutions like Redux or local component state can sometimes fall short when dealing with intricate user interfaces and complex business logic. Enter state machines and, more specifically, XState - a powerful library that brings the concept of finite state machines to the JavaScript ecosystem. In this article, we'll explore how to leverage XState in React applications to manage complex UI logic effectively.

Understanding State Machines: Before diving into XState, it's crucial to understand what state machines are. A finite state machine is a mathematical model of computation that describes a system which can be in only one of a finite number of states at any given time. Transitions between these states are triggered by specific events or conditions.

In the context of user interfaces, state machines can represent different screens, modes, or states that an application can be in. They provide a clear, visual way to model complex behaviors and interactions.

Enter XState: XState is a JavaScript library that brings the power of state machines and statecharts to the JavaScript and TypeScript ecosystem. It provides a way to create, interpret, and execute finite state machines and statecharts in JavaScript applications.

Key benefits of using XState include:

  1. Predictability: State machines make it clear which states are possible and how transitions occur.

  2. Visualization: XState provides tools to visualize your application's state machine.

  3. Testing: State machines are easier to test as they have a finite number of states and transitions.

  4. Scalability: As complexity grows, state machines remain manageable.

Setting Up XState in a React Project: To get started with XState in a React project, first install the necessary packages:

npm install xstate @xstate/react

Creating a Simple State Machine: Let's start with a simple example - a toggle button. Here's how we can model this using XState:

import { createMachine } from 'xstate';

const toggleMachine = createMachine({
  id: 'toggle',
  initial: 'inactive',
  states: {
    inactive: { on: { TOGGLE: 'active' } },
    active: { on: { TOGGLE: 'inactive' } }
  }
});

This machine has two states: 'inactive' and 'active'. The 'TOGGLE' event causes a transition between these states.

Using the Machine in a React Component: To use this machine in a React component, we'll use the useMachine hook from '@xstate/react':

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

function Toggle() {
  const [state, send] = useMachine(toggleMachine);

  return (
    <button onClick={() => send('TOGGLE')}>
      {state.value === 'inactive' ? 'Off' : 'On'}
    </button>
  );
}

The useMachine hook returns the current state and a send function to trigger events. We use these to control our UI.

Complex UI Logic with XState: Now, let's explore a more complex example - a multi-step form. This is where XState truly shines.

import { createMachine, assign } from 'xstate';

const formMachine = createMachine({
  id: 'form',
  initial: 'personal',
  context: {
    name: '',
    email: '',
    age: '',
    occupation: ''
  },
  states: {
    personal: {
      on: {
        NEXT: {
          target: 'contact',
          cond: 'isPersonalValid'
        },
        UPDATE: {
          actions: 'updateField'
        }
      }
    },
    contact: {
      on: {
        PREV: 'personal',
        NEXT: {
          target: 'review',
          cond: 'isContactValid'
        },
        UPDATE: {
          actions: 'updateField'
        }
      }
    },
    review: {
      on: {
        PREV: 'contact',
        SUBMIT: 'submitting'
      }
    },
    submitting: {
      invoke: {
        src: 'submitForm',
        onDone: 'success',
        onError: 'failure'
      }
    },
    success: {
      type: 'final'
    },
    failure: {
      on: {
        RETRY: 'submitting'
      }
    }
  }
}, {
  actions: {
    updateField: assign((context, event) => ({
      [event.field]: event.value
    }))
  },
  guards: {
    isPersonalValid: (context) => context.name && context.age,
    isContactValid: (context) => context.email
  },
  services: {
    submitForm: (context) => {
      // API call simulation
      return new Promise((resolve) => {
        setTimeout(() => resolve(context), 2000);
      });
    }
  }
});

This machine models a multi-step form with personal information, contact details, and a review step. It includes validation, back/forward navigation, and form submission.

Implementing the Form Component: Here's how we might implement this form using our state machine:

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

function MultiStepForm() {
  const [state, send] = useMachine(formMachine);

  const handleChange = (e) => {
    send('UPDATE', { field: e.target.name, value: e.target.value });
  };

  if (state.matches('personal')) {
    return (
      <form onSubmit={() => send('NEXT')}>
        <input name="name" onChange={handleChange} value={state.context.name} />
        <input name="age" onChange={handleChange} value={state.context.age} />
        <button type="submit">Next</button>
      </form>
    );
  }

  if (state.matches('contact')) {
    return (
      <form onSubmit={() => send('NEXT')}>
        <input name="email" onChange={handleChange} value={state.context.email} />
        <button onClick={() => send('PREV')}>Back</button>
        <button type="submit">Next</button>
      </form>
    );
  }

  if (state.matches('review')) {
    return (
      <div>
        <h2>Review your information:</h2>
        <p>Name: {state.context.name}</p>
        <p>Age: {state.context.age}</p>
        <p>Email: {state.context.email}</p>
        <button onClick={() => send('PREV')}>Back</button>
        <button onClick={() => send('SUBMIT')}>Submit</button>
      </div>
    );
  }

  if (state.matches('submitting')) {
    return <div>Submitting...</div>;
  }

  if (state.matches('success')) {
    return <div>Form submitted successfully!</div>;
  }

  if (state.matches('failure')) {
    return (
      <div>
        <p>Submission failed. Please try again.</p>
        <button onClick={() => send('RETRY')}>Retry</button>
      </div>
    );
  }

  return null;
}

This component uses the state.matches() method to determine which part of the form to render. The send function is used to trigger state transitions.

Benefits of This Approach:

  1. Clear Flow: The state machine clearly defines the possible states and transitions of our form.

  2. Separation of Concerns: The machine handles the logic, while the React component focuses on rendering.

  3. Easy to Extend: Adding new steps or validation rules is straightforward.

  4. Predictable Behavior: It's clear what can happen in each state.

Visualization and Debugging: One of the great features of XState is the ability to visualize your state machines. The XState Visualizer (https://xstate.js.org/viz/) allows you to paste your machine definition and see a visual representation of your states and transitions. This can be invaluable for debugging and communicating with team members.

Testing: State machines also make testing easier. You can test individual transitions:

import { interpret } from 'xstate';

test('should transition to contact when NEXT is sent', () => {
  const service = interpret(formMachine).start();
  service.send({ type: 'UPDATE', field: 'name', value: 'John' });
  service.send({ type: 'UPDATE', field: 'age', value: '30' });
  service.send('NEXT');
  expect(service.state.value).toBe('contact');
});