React · Intermediate
A form component with every state, and tests that act like users
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
statusandalert.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
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.
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.");
});
});import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: { environment: "jsdom" },
});$ 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
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.
Connect
onTipto the TipJar contract on a local Anvil chain with viem, and show the transaction hash after sending.