Skip to the code
cf.

Form state machine reducer

The tea shop's newsletter form has an email box and a Subscribe button. A first version used separate flags, isSending, isSent, hasError, and soon showed a spinner and an error at the same time. Model it as a small state machine instead, where impossible states can't be written down.

The state is one object with a single status. Export initialState by name: { status: "editing", email: "", error: null }. The four shapes are:

{ status: "editing",    email, error: null }
{ status: "submitting", email, error: null }
{ status: "success",    email, error: null }
{ status: "error",      email, error: "the message" }

Export formReducer(state, action) by name. It's pure: it never changes the state it's given. These are its actions:

{ type: "edited", email }   // editing or error: back to editing with the new email
{ type: "submitted" }        // editing or error, with a non-blank email: to submitting
{ type: "succeeded" }        // submitting: to success
{ type: "failed", error }    // submitting: to error, with the message

Any of those actions in a status not listed for it, and submitted with an email that's empty or only spaces, changes nothing: the reducer returns the very same state object. An unknown type throws an Error with the message Unknown action: and the type.

The default export, App, is the form, driven by useReducer(formReducer, initialState). It takes onSubscribe(email), which returns a promise; when it isn't given, as in the Preview, use a pretend one that waits a moment. The form has a text box labelled Email and a submit button. Submitting calls onSubscribe once with the email, then dispatches succeeded if the promise resolves, or failed with the error's message if it rejects.

What shows follows the status. The button reads Subscribing... while submitting and Subscribe otherwise, and it's disabled while submitting or while the email is blank. The email box is disabled while submitting. In error, the message shows in an element with role="alert", and editing the email clears it. In success, the form is replaced by <p role="status">You're subscribed as ana@example.com.</p> with the email.

What the tests check

  1. walks from editing through submitting to success
    expect(initialState).toEqual({ status: "editing", email: "", error: null });
    let state = formReducer(initialState, { type: "edited", email: "ana@example.com" });
    expect(state).toEqual({ status: "editing", email: "ana@example.com", error: null });
    state = formReducer(state, { type: "submitted" });
    expect(state).toEqual({ status: "submitting", email: "ana@example.com", error: null });
    state = formReducer(state, { type: "succeeded" });
    expect(state).toEqual({ status: "success", email: "ana@example.com", error: null });
    
  2. a failure keeps the email and the message, and editing or retrying clears it
    const submitting = { status: "submitting", email: "ana@example.com", error: null };
    const failed = formReducer(submitting, { type: "failed", error: "Try again later." });
    expect(failed).toEqual({ status: "error", email: "ana@example.com", error: "Try again later." });
    expect(formReducer(failed, { type: "edited", email: "ana@example.org" })).toEqual({ status: "editing", email: "ana@example.org", error: null });
    expect(formReducer(failed, { type: "submitted" })).toEqual({ status: "submitting", email: "ana@example.com", error: null });
    
  3. ignores actions that don't fit the current status
    const editing = { status: "editing", email: "ana@example.com", error: null };
    const submitting = { status: "submitting", email: "ana@example.com", error: null };
    const success = { status: "success", email: "ana@example.com", error: null };
    const error = { status: "error", email: "ana@example.com", error: "Nope." };
    expect(formReducer(editing, { type: "succeeded" })).toBe(editing);
    expect(formReducer(editing, { type: "failed", error: "Nope." })).toBe(editing);
    expect(formReducer(submitting, { type: "edited", email: "bo@example.com" })).toBe(submitting);
    expect(formReducer(submitting, { type: "submitted" })).toBe(submitting);
    expect(formReducer(success, { type: "edited", email: "bo@example.com" })).toBe(success);
    expect(formReducer(success, { type: "submitted" })).toBe(success);
    expect(formReducer(success, { type: "failed", error: "Nope." })).toBe(success);
    expect(formReducer(error, { type: "succeeded" })).toBe(error);
    
  4. won't submit an empty or blank email
    const empty = { status: "editing", email: "", error: null };
    const blank = { status: "editing", email: "   ", error: null };
    expect(formReducer(empty, { type: "submitted" })).toBe(empty);
    expect(formReducer(blank, { type: "submitted" })).toBe(blank);
    
  5. never changes the state it's given, and throws on unknown actions
    const editing = Object.freeze({ status: "editing", email: "ana@example.com", error: null });
    const submitting = Object.freeze({ status: "submitting", email: "ana@example.com", error: null });
    const error = Object.freeze({ status: "error", email: "ana@example.com", error: "Nope." });
    formReducer(editing, { type: "edited", email: "bo@example.com" });
    formReducer(editing, { type: "submitted" });
    formReducer(submitting, { type: "succeeded" });
    formReducer(submitting, { type: "failed", error: "Nope." });
    formReducer(error, { type: "edited", email: "bo@example.com" });
    expect(editing.email).toBe("ana@example.com");
    expect(submitting.status).toBe("submitting");
    expect(error.error).toBe("Nope.");
    expect(() => formReducer(editing, { type: "cancelled" })).toThrow("Unknown action: cancelled");
    
  6. Subscribe is disabled until there's an email
    render(<App onSubscribe={() => new Promise(() => {})} />);
    const button = screen.getByRole("button", { name: "Subscribe" });
    expect(button).toBeDisabled();
    await user.type(screen.getByLabelText("Email"), "   ");
    expect(button).toBeDisabled();
    await user.clear(screen.getByLabelText("Email"));
    await user.type(screen.getByLabelText("Email"), "ana@example.com");
    expect(button).toBeEnabled();
    
  7. while it sends, the button reads Subscribing... and nothing can be changed
    const request = deferred();
    const onSubscribe = spy(() => request.promise);
    render(<App onSubscribe={onSubscribe} />);
    await user.type(screen.getByLabelText("Email"), "ana@example.com");
    await user.click(screen.getByRole("button", { name: "Subscribe" }));
    const button = screen.getByRole("button", { name: "Subscribing..." });
    expect(button).toBeDisabled();
    expect(screen.getByLabelText("Email")).toBeDisabled();
    await user.click(button);
    expect(onSubscribe).toHaveBeenCalledTimes(1);
    expect(onSubscribe).toHaveBeenCalledWith("ana@example.com");
    expect(screen.queryByRole("alert")).not.toBeInTheDocument();
    
  8. shows the thank-you message when it works
    const request = deferred();
    render(<App onSubscribe={() => request.promise} />);
    await user.type(screen.getByLabelText("Email"), "ana@example.com");
    await user.click(screen.getByRole("button", { name: "Subscribe" }));
    request.resolve();
    expect(await screen.findByRole("status")).toHaveTextContent("You're subscribed as ana@example.com.");
    expect(screen.queryByLabelText("Email")).not.toBeInTheDocument();
    expect(screen.queryByRole("button")).not.toBeInTheDocument();
    
  9. shows the error when it fails, and lets you edit and try again
    let attempt = 0;
    const onSubscribe = spy(() => {
      attempt++;
      return attempt === 1 ? Promise.reject(new Error("That address is already subscribed.")) : new Promise(() => {});
    });
    render(<App onSubscribe={onSubscribe} />);
    await user.type(screen.getByLabelText("Email"), "ana@example.com");
    await user.click(screen.getByRole("button", { name: "Subscribe" }));
    expect(await screen.findByRole("alert")).toHaveTextContent("That address is already subscribed.");
    const input = screen.getByLabelText("Email");
    expect(input).toHaveValue("ana@example.com");
    expect(input).toBeEnabled();
    expect(screen.getByRole("button", { name: "Subscribe" })).toBeEnabled();
    await user.type(input, "m");
    expect(screen.queryByRole("alert")).not.toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Subscribe" }));
    expect(screen.getByRole("button", { name: "Subscribing..." })).toBeDisabled();
    expect(onSubscribe).toHaveBeenLastCalledWith("ana@example.comm");
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).