bhargavshukla

Fetch using XState

Jul 1, 2024

Continuing my foray into learning XState, I want to build a re-usable fetch hook with support for cancelling duplicate requests. Besides XState, I'm going to use axios since its API has a few niceties like automatic response handling, automatic request serialization, support for request/response data transforms among many others. My requirement is simple: I want a hook that takes in some parameters for an HTTP request, and returns the response, any errors, a loading indicator, and the ability to retry the request.

Designing the hook's API

Before I implement the state machine, I want to set up the hook - specifically, what it returns. Also, since I'm using axios, I can rely on its config API to accept a request object, so in my machine I can pass it through to an axios instance. I'm also going to define the useMachine hook here, so I can use it to design the machine. Here's what that looks like:

function useFetch(requestConfig, abortController = null) {
  let [state, send] = useMachine(fetchMachine, {
    input: {
      requestConfig,
      abortController: abortController ?? undefined
    }
  });
  let {response, error} = state.context;
  let isLoading = state.matches('fetching');

  return {response, error, isLoading, mutate: send({type: 'RETRY'})};
}

I'm working based on a few assumptions about my machine: it's going to have a state called fetching, it'll accept an event called RETRY, and that it accepts the request config that's passed to the hook. I also want to be able to pass an AbortController to my hook, in case I want to override the machine's request cancellation behaviour. Keeping these in mind, I'm going to implement the machine next. There are 2 concepts in XState that I'll mainly rely on to create my machine, promise actors and the invoke property.

export default setup({
  actors: {
    callAxios: fromPromise(async ({input}) => {
      let response = await axios({...input.requestData, signal: input.abortController.signal});
      input.abortController.abort();
      return response;
    })
  }
}).createMachine({
  id: 'fetch-machine',
  context: ({input}) => ({
    abortController: input.abortController ?? new AbortController(),
    requestData: input.requestData,
    response: null,
    error: null
  }),
  initial: 'fetching',
  states: {
    'fetching': {
      invoke: {
        id: 'fetch-service',
        src: 'callAxios',
        input: ({context: {requestData, abortController}}) => ({requestData, abortController}),
        onDone: {
          target: 'success',
          actions: assign({response: ({event}) => event.output})
        },
        onError: {
          target: 'failure',
          actions: assign({
            error: ({event}) => ({
              status: event.error.status,
              code: event.error.code,
              message: event.error.message
            })
          }),
        }
      }
    },
    'success': {
      on: {
        'RETRY': {target: 'fetching'}
      }
    },
    'failure': {
      on: {
        'RETRY': {target: 'fetching'}
      }
    }
  }
});

The fetching state is the one I want to focus on. When the machine enters the fetching state, it invokes a promise actor called callAxios. I pass a request config object to the machine as input, which gets passed on to the callAxios actor, and callAxios in turn calls axios with that request object, returning the result. The great thing about axios is that it throws an error even where fetch doesn't, like when an API returns a 404 response. Because of this behaviour, the promise actor will correctly handle 404 responses as errors without requiring any extra code. Another good thing is that promise actors in XState send onDone and onError events based on whether a promise resolves or rejects, and conveniently maps the responses and errors to separate variables called output and error respectively. This makes processing them super straightforward! I'm assigning them to the response and error context variables in the machine using assign actions. The RETRY event will handle any mutations I want to trigger.

Now that I have my machine and hook implemented, how do I use them? Let me show you an example. In the React component below, I'm using the PokeAPI to demonstrate usage of the hook.

const ROOT_CONFIG = {
  method: "GET",
  baseURL: "https://pokeapi.co/api/v2/",
}

function App() {
  let {response: pikachuData, error: pikachuError, isLoading: isLoadingPikachu, mutate: mutatePikachu} = useFetch({
    ...ROOT_CONFIG,
    url: "pokemon/pikachu"
  }, new AbortController());
  let {
    response: bulbasaurData,
    error: bulbasaurError,
    isLoading: isLoadingBulbasaur,
    mutate: mutateBulbasaur
  } = useFetch({
    ...ROOT_CONFIG,
    url: "pokemon/bulbasaur"
  });

  return (
    <div>
      <details>
        <summary>Pikachu</summary>
        {isLoadingPikachu ? (
          <p>Loading pikachu data...</p>
        ) : null}
        {pikachuError ? (
          <div role="alert">
            {JSON.stringify(pikachuError, null, 2)}
            <button onClick={mutatePikachu}>RETRY</button>
          </div>
        ) : null}
        {pikachuData ? (<pre>{JSON.stringify(pikachuData, null, 2)}</pre>) : null}
      </details>
      <details>
        <summary>Bulbasaur</summary>
        {isLoadingBulbasaur ? (
          <p>Loading bulbasaur data...</p>
        ) : null}
        {bulbasaurError ? (
          <div role="alert">
            {JSON.stringify(bulbasaurError, null, 2)}
            <button onClick={mutateBulbasaur}>RETRY</button>
          </div>
        ) : null}
        {bulbasaurData ? (<pre>{JSON.stringify(bulbasaurData, null, 2)}</pre>) : null}
      </details>
    </div>
  )
}

export default App

Looks pretty clean, right? Even after all that, I've barely scratched the surface. There are so many ways to enhance this. I could implement background refresh using a delayed transition on the success/failure states, or add some pre/post processing of the request/response data, or flesh out the isLoading variable to distinguish between foreground and background fetches (React Query does this). The possibilities are endless!

If you have any questions or feedback about this article, feel free to email me at feedback (@) thesilverhand (.) blog.

← Next Article

FSM driven wizard forms

Previous Article →

My new favourite keyboard