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.
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 });
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 });
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);
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);
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");
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();
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();
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();
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");
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).
I'm here to help with Form state machine reducer. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.