A small tea shop sends a monthly newsletter. Build NewsletterForm, the default export: a form with a text box labelled Email (its name is email) and a Subscribe button. Run the sign-up as the form's action, with useActionState.
It takes one prop, subscribe(email), which returns a promise. It resolves to { ok: true } when it works, or { ok: false, error: "That address is already subscribed." } when the shop says no, and it rejects when the network fails. The default (already in the starter) is a pretend server that answers after a short wait, so the Preview works.
When the form is submitted, read the email from the form data and trim the spaces around it. If it has no @ in it, don't call subscribe: show Enter a valid email. instead. Otherwise call subscribe with the trimmed email.
While the action runs, the button says Subscribing… (with the single … character) and is disabled. Afterwards it says Subscribe again.
When it works, show Thanks! asha@tea.shop is subscribed. (with the email) and leave the box empty. When the shop says no, show its error. When subscribe rejects, show Something went wrong. Try again. and don't let the error escape the action.
Every error goes in an element the input points to with aria-describedby, and the input gets aria-invalid="true" while there's an error. After an error, the box still holds what was typed, so the user can fix it. A later success clears the error.
<NewsletterForm subscribe={subscribe} />
// While it waits: [ asha@tea.shop ] [Subscribing…] (disabled)
// When it works: [ ] [Subscribe]
// Thanks! asha@tea.shop is subscribed.
// When the shop says no, the box keeps "asha@tea.shop" and shows the error.
const answer = deferred();
const subscribe = spy(() => answer.promise);
render(<App subscribe={subscribe} />);
await user.type(screen.getByLabelText("Email"), "asha@tea.shop");
await user.click(screen.getByRole("button", { name: "Subscribe" }));
expect(subscribe).toHaveBeenCalledTimes(1);
expect(subscribe).toHaveBeenCalledWith("asha@tea.shop");
answer.resolve({ ok: true });
await flush();
const subscribe = spy(async () => ({ ok: true }));
render(<App subscribe={subscribe} />);
await user.type(screen.getByLabelText("Email"), " asha@tea.shop ");
await user.click(screen.getByRole("button", { name: "Subscribe" }));
await flush();
expect(subscribe).toHaveBeenCalledWith("asha@tea.shop");
const answer = deferred();
render(<App subscribe={() => answer.promise} />);
await user.type(screen.getByLabelText("Email"), "asha@tea.shop");
await user.click(screen.getByRole("button", { name: "Subscribe" }));
expect(screen.getByRole("button", { name: "Subscribing…" })).toBeDisabled();
answer.resolve({ ok: true });
expect(await screen.findByRole("button", { name: "Subscribe" })).toBeEnabled();
const answer = deferred();
render(<App subscribe={() => answer.promise} />);
const box = screen.getByLabelText("Email");
await user.type(box, "asha@tea.shop");
await user.click(screen.getByRole("button", { name: "Subscribe" }));
answer.resolve({ ok: true });
expect(await screen.findByText("Thanks! asha@tea.shop is subscribed.")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toHaveValue("");
expect(screen.getByLabelText("Email")).not.toHaveAttribute("aria-invalid", "true");
const subscribe = spy(async () => ({ ok: true }));
render(<App subscribe={subscribe} />);
await user.type(screen.getByLabelText("Email"), "asha");
await user.click(screen.getByRole("button", { name: "Subscribe" }));
expect(await screen.findByText("Enter a valid email.")).toBeInTheDocument();
expect(subscribe).toHaveBeenCalledTimes(0);
const box = screen.getByLabelText("Email");
expect(box).toHaveAttribute("aria-invalid", "true");
expect(document.getElementById(box.getAttribute("aria-describedby"))).toHaveTextContent("Enter a valid email.");
render(<App subscribe={async () => ({ ok: false, error: "That address is already subscribed." })} />);
await user.type(screen.getByLabelText("Email"), "mei@tea.shop");
await user.click(screen.getByRole("button", { name: "Subscribe" }));
expect(await screen.findByText("That address is already subscribed.")).toBeInTheDocument();
const box = screen.getByLabelText("Email");
expect(box).toHaveValue("mei@tea.shop");
expect(box).toHaveAttribute("aria-invalid", "true");
expect(document.getElementById(box.getAttribute("aria-describedby"))).toHaveTextContent("That address is already subscribed.");
render(<App subscribe={() => Promise.reject(new TypeError("Failed to fetch"))} />);
await user.type(screen.getByLabelText("Email"), "asha@tea.shop");
await user.click(screen.getByRole("button", { name: "Subscribe" }));
expect(await screen.findByText("Something went wrong. Try again.")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toHaveValue("asha@tea.shop");
expect(screen.getByRole("button", { name: "Subscribe" })).toBeEnabled();
let calls = 0;
render(<App subscribe={async () => (++calls === 1 ? { ok: false, error: "Try a different address." } : { ok: true })} />);
const box = screen.getByLabelText("Email");
await user.type(box, "mei@tea.shop");
await user.click(screen.getByRole("button", { name: "Subscribe" }));
expect(await screen.findByText("Try a different address.")).toBeInTheDocument();
await user.clear(screen.getByLabelText("Email"));
await user.type(screen.getByLabelText("Email"), "asha@tea.shop");
await user.click(screen.getByRole("button", { name: "Subscribe" }));
expect(await screen.findByText("Thanks! asha@tea.shop is subscribed.")).toBeInTheDocument();
expect(screen.queryByText("Try a different address.")).not.toBeInTheDocument();
expect(screen.getByLabelText("Email")).not.toHaveAttribute("aria-invalid", "true");
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 Newsletter form with useActionState. 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.