Skip to the code
cf.

Submit button with useFormStatus

The tea shop's admin pages have many forms, and every one needs a button that says it's busy while the form saves. Build SubmitButton (a named export) so no form has to pass it a pending prop.

SubmitButton takes children, the button's label, which defaults to Save. It's a submit button. While the action of the form it sits in is running, it shows Saving… (with the single … character) instead of its label and is disabled. Before and after, it shows its label and is enabled.

Use useFormStatus from react-dom: it reports on the form the component is rendered inside, so two forms on the same page each have their own status.

The default export App is already written: a form that saves a tasting note with its save(note) prop and then shows Saved: followed by the note. It uses your button with the label Save note.

<form action={saveNote}>
  <textarea name="note" />
  <SubmitButton>Save note</SubmitButton>
</form>
 
// Idle:    [Save note]
// Saving:  [Saving…]   (disabled)

What the tests check

  1. shows its label and is enabled when the form is idle
    render(
      <form action={() => {}}>
        <SubmitButton>Order</SubmitButton>
      </form>,
    );
    expect(screen.getByRole("button", { name: "Order" })).toBeEnabled();
    
  2. uses Save when it has no children
    render(
      <form action={() => {}}>
        <SubmitButton />
      </form>,
    );
    expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
    
  3. submits the form it's in
    const action = spy();
    render(
      <form action={action}>
        <input name="tea" defaultValue="Sencha" aria-label="Tea" />
        <SubmitButton>Order</SubmitButton>
      </form>,
    );
    await user.click(screen.getByRole("button", { name: "Order" }));
    expect(action).toHaveBeenCalledTimes(1);
    expect(action.calls[0].args[0].get("tea")).toBe("Sencha");
    
  4. says Saving… and is disabled while the action runs
    const answer = deferred();
    render(
      <form action={() => answer.promise}>
        <SubmitButton>Order</SubmitButton>
      </form>,
    );
    await user.click(screen.getByRole("button", { name: "Order" }));
    expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
    answer.resolve();
    await flush();
    
  5. shows its label again when the action finishes
    const answer = deferred();
    render(
      <form action={() => answer.promise}>
        <SubmitButton>Order</SubmitButton>
      </form>,
    );
    await user.click(screen.getByRole("button", { name: "Order" }));
    answer.resolve();
    expect(await screen.findByRole("button", { name: "Order" })).toBeEnabled();
    expect(screen.queryByText("Saving…")).not.toBeInTheDocument();
    
  6. only reports on its own form
    const answer = deferred();
    render(
      <>
        <form action={() => answer.promise}>
          <SubmitButton>Order</SubmitButton>
        </form>
        <form action={() => {}}>
          <SubmitButton>Review</SubmitButton>
        </form>
      </>,
    );
    await user.click(screen.getByRole("button", { name: "Order" }));
    expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
    expect(screen.getByRole("button", { name: "Review" })).toBeEnabled();
    answer.resolve();
    await flush();
    
  7. App's Save note button says Saving… until save answers
    const answer = deferred();
    const save = spy(() => answer.promise);
    render(<App save={save} />);
    await user.type(screen.getByLabelText("Tasting note"), "Grassy and sweet");
    await user.click(screen.getByRole("button", { name: "Save note" }));
    expect(save).toHaveBeenCalledWith("Grassy and sweet");
    expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
    answer.resolve();
    expect(await screen.findByText("Saved: Grassy and sweet")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Save note" })).toBeEnabled();
    

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