React · Intermediate

A form component with every state, and tests that act like users

4 min read

Why it matters

React is the most common way to build front ends for dApps and many products. Most front-end bugs are missed states: a double click while a transaction is pending, an error nobody sees, an input a screen reader cannot name. Handling every state and testing through the accessible interface prevents them.

The concept

  • A component is a function that returns UI from props (inputs) and state (useState).

  • Controlled inputs keep form values in state, so validation and submit logic see the same values.

  • Async actions (sending a transaction, calling an API) have states: idle, sending, done and failed. Show each one and prevent double submission.

  • Accessibility: label every input, use real buttons, and announce results with roles such as status and alert.

  • Testing Library finds elements by role and label, the way users and assistive technology do, instead of by CSS classes.

Worked example: the tip form

src/TipForm.tsx
import { useState } from "react";

type Props = {
  onTip: (amount: number, message: string) => Promise<void>;
};

export function TipForm({ onTip }: Props) {
  const [amount, setAmount] = useState("");
  const [message, setMessage] = useState("");
  const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");

  const value = Number(amount);
  const valid = amount !== "" && Number.isFinite(value) && value > 0;

  async function submit(event: React.FormEvent) {
    event.preventDefault();
    if (!valid) return;
    setStatus("sending");
    try {
      await onTip(value, message);
      setStatus("sent");
      setAmount("");
      setMessage("");
    } catch {
      setStatus("error");
    }
  }

  return (
    <form onSubmit={submit} aria-label="Send a tip">
      <label>
        Amount (ETH)
        <input inputMode="decimal" value={amount} onChange={(e) => setAmount(e.target.value)} />
      </label>
      <label>
        Message
        <input value={message} maxLength={80} onChange={(e) => setMessage(e.target.value)} />
      </label>
      <button type="submit" disabled={!valid || status === "sending"}>
        {status === "sending" ? "Sending..." : "Send tip"}
      </button>
      {status === "sent" && <p role="status">Thank you! Your tip was sent.</p>}
      {status === "error" && <p role="alert">The transaction failed. Nothing was sent.</p>}
    </form>
  );
}

The component does not know about wallets: it receives an onTip function, so the same form works with a real wallet (for example through viem or wagmi) or a test double.

src/TipForm.test.tsx
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TipForm } from "./TipForm";

afterEach(cleanup); // unmount between tests

describe("TipForm", () => {
  it("disables the button until the amount is valid", async () => {
    render(<TipForm onTip={vi.fn()} />);
    const button = screen.getByRole("button", { name: "Send tip" });
    expect(button).toHaveProperty("disabled", true);
    await userEvent.type(screen.getByLabelText("Amount (ETH)"), "0");
    expect(button).toHaveProperty("disabled", true);
    await userEvent.type(screen.getByLabelText("Amount (ETH)"), ".25");
    expect(button).toHaveProperty("disabled", false);
  });

  it("sends the tip and confirms", async () => {
    const onTip = vi.fn().mockResolvedValue(undefined);
    render(<TipForm onTip={onTip} />);
    await userEvent.type(screen.getByLabelText("Amount (ETH)"), "0.25");
    await userEvent.type(screen.getByLabelText("Message"), "great talk");
    await userEvent.click(screen.getByRole("button", { name: "Send tip" }));
    expect(onTip).toHaveBeenCalledWith(0.25, "great talk");
    expect(await screen.findByRole("status")).toHaveProperty("textContent", "Thank you! Your tip was sent.");
  });

  it("shows an error when the transaction fails", async () => {
    render(<TipForm onTip={vi.fn().mockRejectedValue(new Error("user rejected"))} />);
    await userEvent.type(screen.getByLabelText("Amount (ETH)"), "1");
    await userEvent.click(screen.getByRole("button", { name: "Send tip" }));
    expect(await screen.findByRole("alert")).toHaveProperty("textContent", "The transaction failed. Nothing was sent.");
  });
});
vitest.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";

export default defineConfig({
  plugins: [react()],
  test: { environment: "jsdom" },
});
shellsession
$ npm i react react-dom
$ npm i -D vitest jsdom @testing-library/react @testing-library/user-event @vitejs/plugin-react typescript @types/react
$ npx vitest run
 Test Files  1 passed (1)
      Tests  3 passed (3)

The first version of these tests failed with "Found multiple elements with the text of: Amount (ETH)": without globals enabled, Testing Library does not unmount components between tests, so earlier forms were still on the page. The explicit afterEach(cleanup) fixes it.

Common mistakes

  • Only the happy path. Every async action needs pending, success and error states.

  • Double submissions. Disable the button while sending.

  • Unlabelled inputs and clickable divs, which keyboard and screen-reader users cannot use.

  • Testing implementation details (state variables, class names) instead of what users see.

Interview angle

"What is the difference between props and state?" "What is a controlled input?" "How do you handle loading and errors for an async action?" "How do you test a component?" (render it, interact through roles and labels, assert on what is visible).

Practice task

  1. Add a check that the amount is at most the user's balance, passed in as a prop, with a visible explanation when it is too high.

  2. Connect onTip to the TipJar contract on a local Anvil chain with viem, and show the transaction hash after sending.

Further reading