Skip to the code
cf.

Newsletter form with useActionState

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.

What the tests check

  1. calls subscribe with the typed email
    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();
    
  2. trims the spaces around the email
    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");
    
  3. shows Subscribing… on a disabled button while it waits
    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();
    
  4. thanks the user and empties the box when it works
    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");
    
  5. an email without @ shows an error and isn't sent
    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.");
    
  6. shows the shop's error and keeps what was typed
    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.");
    
  7. a network failure shows Something went wrong. Try again.
    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();
    
  8. a later success clears the error
    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");
    

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).