0

In trying to wrap my head around xState and state machines in general, I was wondering how you would for example supply an API URL to a form state machine to make it reusable. My current solution is to supply it through withContext, but it feels wrong.

import { Machine, assign } from 'xstate';

const submitForm = async ({ formData, apiURL }) => {
    const res = await fetch(apiURL, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(formData),
    });

    const message = await res.text();

    return { status: res.status, message };
};

const formMachine = Machine({
    id: 'form',
    initial: 'idle',
    context: {
        formData: {},
        apiURL: '',
    },
    states: {
        idle: {
            on: {
                SEND: 'submitted',
                INPUT: {
                    actions: assign({
                        formData: (ctx, { data }) => ({ ...ctx.formData, ...data }),
                    }),
                },
            },
        },
        submitted: {
            id: 'form-submitted',
            initial: 'pending',
            states: {
                pending: {
                    invoke: {
                        id: 'submitForm',
                        src: submitForm,
                        onDone: {
                            target: 'success',
                            actions: assign({
                                result: (ctx, event) => event.data,
                            }),
                        },
                        onError: {
                            target: 'failure',
                            actions: assign({
                                errorMessage: (ctx, event) => event.data,
                            }),
                        },
                    },
                },
                success: {},
                failure: {
                    on: {
                        RETRY: 'pending',
                        SEND: 'pending',
                    },
                },
            },
        },
    },
});

export default formMachine;
import React from 'react';
import { useMachine } from '@xstate/react';
import formMachine from '../data/machines/form';

const ContactForm = () => {
    const contactFormMachine = formMachine.withContext({
        formData: {
            name: '',
            email: '',
            message: '',
        },
        apiURL: '/api/contact',
    });

    const [current, send] = useMachine(contactFormMachine);

    return (
        <>
            {
                current.matches('submitted.success') ? (
                    <div>Message succesfully sent</div>
                ) : (
                    <form onSubmit={
                        (e) => {
                            e.preventDefault();
                            send('SEND');
                        }
                    }>
                        ...
                    </form>
                )
            }
        </>
    );
};

export default ContactForm;
ThomasM
  • 2,647
  • 3
  • 25
  • 30

1 Answers1

0

I think you have a good solution for making that machine re-usable as is. Here is an example from the xstate visualizer repo, which could make you feel more comfortable with your solution:

const invokeSaveGist = (ctx: AppMachineContext, e: EventObject) => {
  return fetch(`https://api.github.com/gists/` + ctx.gist!.id, {
    method: 'post',
    body: JSON.stringify({
      description: 'Generated by XState Viz: https://xstate.js.org/viz',
      public: true,
      files: {
        'machine.js': { content: e.code }
      }
    }),
    headers: {
      Authorization: `token ${ctx.token}`
    }
  }).then(async response => {
    if (!response.ok) {
      throw new Error((await response.json()).message);
    }

    return response.json();
  });
};

As you can see, the "dynamic" part of the url is derived from the machine context here as well, sure, it's the gist id here, but it could just as well be any other part of the url.

Another solution you could consider, although I wouldn't consider it a "better" solution by any means (potentially 3 less lines of code), would be to pass the apiUrl as data when submitting the form with send('SEND');. So instead of:

<form onSubmit={
    (e) => {
        e.preventDefault();
        send('SEND');
    }
}>

you can try:

<form onSubmit={
    (e) => {
        e.preventDefault();
        send('SEND', { apiUrl: 'api/contact'});
    }
}>
TameBadger
  • 1,580
  • 11
  • 15
  • Yeah, I've seen plenty of examples likes this, and feel they're not really the same. In your example you have a "specific" machine for gists, so the base API URL will always be the same. Ofcourse, the gist.id is rightfully in the context, and that way the URL can be derived. I was trying to create a generic form machine. I feel as though formData should be in context, but the API URL doesn't belong there. – ThomasM Jan 08 '20 at 12:38
  • 1
    hmm I see, the next alternative is probably creating a new machine/service for making requests, handling the complete request lifecycle and context there, doing a lookup for a the url based on a property passed to it – TameBadger Jan 08 '20 at 12:50