Handling User Inputs and Forms page
Use forms and inputs to collect information.
Overview
In this section, we will:
- Learn about controlled vs. uncontrolled inputs
- Work with change events
- Use TypeScript’s
Record
interface - Set state using an updater function
- Updating reference types and rendering
- Learn about the
useId()
Hook
Objective 1: Add checkboxes to order menu items
Now that we have our RestaurantOrder
page, let’s start building the form for placing an order!
We’ll start with checkboxes to select menu items, with a message that warns users when no items are selected and a total that shows the sum of the selected items.
Controlled vs. uncontrolled inputs
React has special handling for <input>
components that allow developers to create “controlled” or “uncontrolled” inputs. An input is uncontrolled when its value
(or checked
) prop is not set, and it does not have a handler set for the onChange
prop; an initial value may be set using the defaultValue
prop.
An input is controlled when both its value
(or checked
) and onChange
props have values set. The term “controlled” refers to the fact that the value of the input is controlled by React. Most of the time, we want our <input>
components to be controlled with their value stored in a state variable.
Note: If an <input>
only has the value
or onChange
prop set, React will log a warning to the console in development mode.
Let’s take a look at an example of a controlled input:
import { useState } from "react"
const ControlledInput: React.FC = () => {
const [name, setName] = useState("")
return (
<label>
Name:
<input
type="text"
onChange={(event) => setName(event.target.value)}
value={name}
/>
</label>
)
}
Controlled components aren’t allowed to have a value of null
or undefined
. To set an input with “no value,” use an empty string: ""
.
Working with change events
In the previous example, the <input>
prop onChange
had its value set to a function known as an “event handler.”
import { useState } from "react"
const ControlledInput: React.FC = () => {
const [name, setName] = useState("")
return (
<label>
Name:
<input
type="text"
onChange={(event) => setName(event.target.value)}
value={name}
/>
</label>
)
}
When an event occurs, such as a user typing in an input field, an event
object is passed to the event handler function. This event object contains various properties and methods that provide information about the event.
One such property is target
, which is a reference to the DOM element that triggered the event. In the case of an input field, target would refer to the specific <input>
element where the user is typing.
The property event.target.value
is particularly important when dealing with input fields. The value property here holds the current content of the input field. It represents what the user has entered or selected. When you access event.target.value
in your event handler function, you’re essentially retrieving the latest input provided by the user. This is commonly used to update the state of the component with the new input value, ensuring that the component’s state is in sync with what the user is entering.
For most input types, you’ll want to use event.target.value
to get the value entered. But there are exceptions! For <input type="checkbox">
, you’ll want to use event.target.checked
instead:
import { useState } from "react"
const TodoItem: React.FC = () => {
const [isCompleted, setIsCompleted] = useState(false)
return (
<label>
<input
type="checkbox"
checked={isCompleted}
onChange={(event) => setIsCompleted(event.target.checked)}
/>
Completed
</label>
)
}
Using TypeScript’s Record
interface
In our upcoming exercise, we want to store information in a JavaScript object. We also want to use TypeScript so we can constrain the types used as keys and values.
TypeScript provides a handy interface named Record
that we can use. Record
is a generic interface that requires two types: the first is the type of the keys, and the second is the type of the values.
For example, if we’re recording the items in a list that are selected, we might capture the item’s name and whether or not it’s selected like this:
import { useState } from "react"
const landmarks = [
{ id: "0b90c705", name: "Eiffel Tower" },
{ id: "5be758c1", name: "Machu Picchu" },
{ id: "206025c3", name: "Taj Mahal" },
]
type SelectedItems = Record<string, number>
const Selected: React.FC = () => {
const [selected, setSelected] = useState<SelectedItems>({})
function handleChange(name: string, isSelected: boolean) {
setSelected((current) => ({ ...current, [name]: isSelected }))
}
return (
<form>
{landmarks.map((landmark) => {
return (
<label key={landmark.id}>
{landmark.name}:
<input
type="checkbox"
checked={selected[landmark.name]}
onChange={(event) =>
handleChange(landmark.name, event.target.checked)
}
/>
</label>
)
})}
</form>
)
}
We’ve explicitly defined the type of useState
as a Record<string, boolean>
; all the keys must be strings, and all the values must be booleans. Fortunately, JavaScript’s object
implements the Record
interface, so we can set the default value to an empty object
instance. Now let’s see how we can use a Record
to store state data.
Setting state using an updater function
One challenge we face when using an object
for state is that we probably need to merge the current state value with the new state value. Why? Imagine we have a state object that already has multiple keys and values, and we need to add a new key and value.
Well, we’re in luck! React already has a solution for this: the setter function returned by useState
will accept an “updater function” that’s passed the “current” state value and should return the “next” state value.
import { useState } from "react"
const landmarks = [
{ id: "0b90c705", name: "Eiffel Tower" },
{ id: "5be758c1", name: "Machu Picchu" },
{ id: "206025c3", name: "Taj Mahal" },
]
type SelectedItems = Record<string, number>
const Selected: React.FC = () => {
const [selected, setSelected] = useState<SelectedItems>({})
function handleSelectedChange(name: string, isSelected: boolean) {
setSelected((currentSelectedItems) => {
const updatedSelectedItems = {
...currentSelectedItems,
}
if (isSelected) {
updatedSelectedItems[name] = true
} else {
delete updatedSelectedItems[name]
}
return updatedSelectedItems
})
}
return (
<form>
{landmarks.map((landmark) => {
return (
<label key={landmark.id}>
{landmark.name}:
<input
type="checkbox"
checked={selected[landmark.name]}
onChange={(event) =>
handleSelectedChange(landmark.name, event.target.checked)
}
/>
</label>
)
})}
</form>
)
}
In the example above, the onChange
event handler calls handleSelectedChange
, which accepts a name string
and a boolean
.
In turn, handleSelectedChange
calls setSelected
with an updater function as the argument. The updater function accepts the currentSelectedItems
argument, which is the object with the currently-selected items before our checkbox was checked.
We will dig into how we create the updatedSelectedItems
object in just a bit, but for now let’s take note that we create a new updatedSelectedItems
object and return it from our updater function. This gives React the updated selected
state and allows React to re-render the component.
Updating reference types and rendering
Now let’s explain how the updater function works in the example above. The updater function does not mutate the current object, then return it; instead, it makes a new object and populates it with the contents of the current object.
This is an important detail because, after the updater function runs, React will compare the values of the current and next objects to determine if they are different. If they are different, React will re-render the Selected
component; if they are the same, then React will do nothing.
The same rules apply when state is an array: create a new array, then update the contents of the new array.
// Adding an item when state (`current`) is an array.
setSelectedOrders((current) => {
const next = [...current, newOrder]
return next
})
// Replacing an item when state (`current`) is an array.
setUpdatedRestaurant((current) => {
const next = [
...current.filter((item) => item.id !== updatedRestaurant.id),
updatedRestaurant,
]
return next
})
OK, that was a lot. Let’s start making some code changes so we can select menu items for an order.
Setup 1
✏️ Update src/pages/RestaurantOrder/RestaurantOrder.tsx to be:
import { useState } from "react"
import { useParams } from "react-router-dom"
import RestaurantHeader from "../../components/RestaurantHeader"
import { useRestaurant } from "../../services/restaurant/hooks"
type OrderItems = Record<string, number>
const RestaurantOrder: React.FC = () => {
const params = useParams() as { slug: string }
const restaurant = useRestaurant(params.slug)
if (restaurant.isPending) {
return (
<p aria-live="polite" className="loading">
Loading restaurant…
</p>
)
}
if (restaurant.error) {
return (
<p aria-live="polite" className="error">
Error loading restaurant: {restaurant.error.message}
</p>
)
}
if (!restaurant.data) {
return <p aria-live="polite">No restaurant found.</p>
}
const subtotal = 0 // Use calculateTotal here.
return (
<>
<RestaurantHeader restaurant={restaurant.data} />
<div className="order-form">
<h3>Order from {restaurant.data.name}!</h3>
<form>
{subtotal === 0 && (
<p className="info text-error">Please choose an item.</p>
)}
<h4>Lunch Menu</h4>
<ul className="list-group">
{restaurant.data.menu.lunch.map(({ name, price }) => (
<li key={name} className="list-group-item">
<label>
<input type="checkbox" />
{name}
<span className="badge">{price}</span>
</label>
</li>
))}
</ul>
<h4>Dinner menu</h4>
<ul className="list-group">
{restaurant.data.menu.dinner.map(({ name, price }) => (
<li key={name} className="list-group-item">
<label>
<input type="checkbox" />
{name}
<span className="badge">{price}</span>
</label>
</li>
))}
</ul>
<div className="submit">
<h4>Total: ${subtotal ? subtotal.toFixed(2) : "0.00"}</h4>
</div>
</form>
</div>
</>
)
}
function calculateTotal(items: OrderItems) {
return Object.values(items).reduce((total, itemPrice) => {
return total + itemPrice
}, 0)
}
export default RestaurantOrder
Verify 1
These tests will pass when the solution has been implemented properly.
✏️ Update src/pages/RestaurantOrder/RestaurantOrder.test.tsx to be:
import "@testing-library/jest-dom"
import type { ReactNode } from "react"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter } from "react-router-dom"
import { describe, expect, it, vi } from "vitest"
import RestaurantOrder from "./RestaurantOrder"
import { useRestaurant } from "../../services/restaurant/hooks"
// Mock the hooks and components used in RestaurantOrder
vi.mock("../../services/restaurant/hooks", () => ({
useRestaurant: vi.fn(),
}))
vi.mock("../../components/RestaurantHeader", () => ({
default: vi.fn(() => (
<div data-testid="mock-restaurant-header">Mock RestaurantHeader</div>
)),
}))
const mockRestaurantData = {
data: {
_id: "1",
name: "Test Restaurant",
slug: "test-restaurant",
images: { owner: "owner.jpg" },
menu: {
lunch: [
{ name: "Lunch Item 1", price: 10 },
{ name: "Lunch Item 2", price: 15 },
],
dinner: [
{ name: "Dinner Item 1", price: 20 },
{ name: "Dinner Item 2", price: 25 },
],
},
},
isPending: false,
error: null,
}
const renderWithRouter = (
ui: ReactNode,
{ route = "/restaurants/test-restaurant" } = {},
) => {
window.history.pushState({}, "Test page", route)
return render(ui, {
wrapper: ({ children }) => (
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
),
})
}
describe("RestaurantOrder component", () => {
it("renders loading state", () => {
useRestaurant.mockReturnValue({ data: null, isPending: true, error: null })
renderWithRouter(<RestaurantOrder />)
expect(screen.getByText(/Loading restaurant…/i)).toBeInTheDocument()
})
it("renders error state", () => {
useRestaurant.mockReturnValue({
data: null,
isPending: false,
error: { message: "Error loading" },
})
renderWithRouter(<RestaurantOrder />)
expect(screen.getByText(/Error loading restaurant/i)).toBeInTheDocument()
})
it("renders no restaurant found state", () => {
useRestaurant.mockReturnValue({ data: null, isPending: false, error: null })
renderWithRouter(<RestaurantOrder />)
expect(screen.getByText(/No restaurant found/i)).toBeInTheDocument()
})
it("renders the RestaurantHeader when data is available", () => {
useRestaurant.mockReturnValue(mockRestaurantData)
renderWithRouter(<RestaurantOrder />)
expect(screen.getByTestId("mock-restaurant-header")).toBeInTheDocument()
})
it("renders the order form when restaurant data is available", () => {
useRestaurant.mockReturnValue(mockRestaurantData)
render(<RestaurantOrder />)
expect(screen.getByTestId("mock-restaurant-header")).toBeInTheDocument()
expect(screen.getByText("Order from Test Restaurant!")).toBeInTheDocument()
expect(screen.getAllByRole("checkbox").length).toBe(4) // 2 lunch + 2 dinner items
})
it("updates subtotal when menu items are selected", async () => {
useRestaurant.mockReturnValue(mockRestaurantData)
render(<RestaurantOrder />)
const checkboxes = screen.getAllByRole("checkbox")
await userEvent.click(checkboxes[0]) // Select 'Lunch Item 1' (price: 10)
expect(screen.getByText("Total: $10.00")).toBeInTheDocument()
await userEvent.click(checkboxes[2]) // Select 'Dinner Item 1' (price: 20)
expect(screen.getByText("Total: $30.00")).toBeInTheDocument()
})
})
Exercise 1
- Call
useState()
and use theOrderItems
interface to create anitems
state. - Create a function for calling
setItems()
with the updateditems
state. - Add the
checked
andonChange
props to all the checkboxes. - Update
subtotal
to use thecalculateTotal()
helper function.
Hint: The items
state will look like this when populated:
const items = {
"Menu item 1 name": 1.23, // Menu item 1 price
"Menu item 2 name": 4.56, // Menu item 2 price
}
Having issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.
Solution 1
Click to see the solution
✏️ Update src/pages/RestaurantOrder/RestaurantOrder.tsx to be:
import { useState } from "react"
import { useParams } from "react-router-dom"
import RestaurantHeader from "../../components/RestaurantHeader"
import { useRestaurant } from "../../services/restaurant/hooks"
type OrderItems = Record<string, number>
const RestaurantOrder: React.FC = () => {
const params = useParams() as { slug: string }
const restaurant = useRestaurant(params.slug)
const [items, setItems] = useState<OrderItems>({})
if (restaurant.isPending) {
return (
<p aria-live="polite" className="loading">
Loading restaurant…
</p>
)
}
if (restaurant.error) {
return (
<p aria-live="polite" className="error">
Error loading restaurant: {restaurant.error.message}
</p>
)
}
if (!restaurant.data) {
return <p aria-live="polite">No restaurant found.</p>
}
const setItem = (itemId: string, isChecked: boolean, itemPrice: number) => {
return setItems((currentItems) => {
const updatedItems = {
...currentItems,
}
if (isChecked) {
updatedItems[itemId] = itemPrice
} else {
delete updatedItems[itemId]
}
return updatedItems
})
}
const subtotal = calculateTotal(items)
return (
<>
<RestaurantHeader restaurant={restaurant.data} />
<div className="order-form">
<h3>Order from {restaurant.data.name}!</h3>
<form>
{subtotal === 0 && (
<p className="info text-error">Please choose an item.</p>
)}
<h4>Lunch Menu</h4>
<ul className="list-group">
{restaurant.data.menu.lunch.map(({ name, price }) => (
<li key={name} className="list-group-item">
<label>
<input
checked={name in items}
onChange={(event) =>
setItem(name, event.target.checked, price)
}
type="checkbox"
/>
{name}
<span className="badge">{price}</span>
</label>
</li>
))}
</ul>
<h4>Dinner menu</h4>
<ul className="list-group">
{restaurant.data.menu.dinner.map(({ name, price }) => (
<li key={name} className="list-group-item">
<label>
<input
checked={name in items}
onChange={(event) =>
setItem(name, event.target.checked, price)
}
type="checkbox"
/>
{name}
<span className="badge">{price}</span>
</label>
</li>
))}
</ul>
<div className="submit">
<h4>Total: ${subtotal ? subtotal.toFixed(2) : "0.00"}</h4>
</div>
</form>
</div>
</>
)
}
function calculateTotal(items: OrderItems) {
return Object.values(items).reduce((total, itemPrice) => {
return total + itemPrice
}, 0)
}
export default RestaurantOrder
Having issues with your local setup? See the solution in StackBlitz or CodeSandbox.
Objective 2: Create a reusable text field component
The order form is going to be made up of many input fields with labels. Rather than repeat multiple input components over and over, let’s compose that structure in a single component named FormTextField
. Creating this component will involve using some of what we’ve learned from prior lessons.
The useId()
Hook
Since the value of every id
attribute in an HTML document must be unique, this Hook is useful in creating a unique identifier string that can be used as the value for an id
prop.
Let’s say you’re rendering a component that has a label
that needs to be associated with an input
:
<label for="name"> Name </label> <input id="name" type="text" />
Every ID has to be unique in an HTML page, but name
might clash with another element in a page. To avoid this issue in React, we can get a unique ID with useId()
:
import { useId } from "react"
const Form: React.FC = () => {
const id = useId()
return (
<>
<label htmlFor={id}>Name</label>
<input id={id} type="text" />
</>
)
}
The value of useId
is guaranteed to be unique within the component where it is used; this ideal for linking related components together.
Setup 2
✏️ Create src/components/FormTextField/FormTextField.tsx and update it to be:
import { useId } from "react"
const FormTextField: React.FC<{}> = ({}) => {
return (
<div className="form-group">
<label className="control-label">Label:</label>
<input className="form-control" />
</div>
)
}
export default FormTextField
✏️ Create src/components/FormTextField/index.ts and update it to be:
export { default } from "./FormTextField"
Verify 2
These tests will pass when the solution has been implemented properly.
✏️ Create src/components/FormTextField/FormTextField.test.tsx and update it to be:
import "@testing-library/jest-dom"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { describe, expect, it, vi } from "vitest"
import FormTextField from "./FormTextField"
describe("FormTextField component", () => {
const mockOnChange = vi.fn()
it("renders with correct label and type", () => {
render(
<FormTextField
label="Test Label"
type="text"
value=""
onChange={mockOnChange}
/>,
)
expect(screen.getByLabelText(/Test Label:/i)).toBeInTheDocument()
expect(screen.getByLabelText(/Test Label:/i)).toHaveAttribute(
"type",
"text",
)
})
it("renders with the correct value", () => {
render(
<FormTextField
label="Test Label"
type="text"
value="Test Value"
onChange={mockOnChange}
/>,
)
expect(screen.getByLabelText(/Test Label:/i)).toHaveValue("Test Value")
})
it("calls onChange prop when input changes", async () => {
render(
<FormTextField
label="Test Label"
type="text"
value=""
onChange={mockOnChange}
/>,
)
await userEvent.type(screen.getByLabelText(/Test Label:/i), "New")
expect(mockOnChange).toHaveBeenCalledTimes(3)
expect(mockOnChange).toHaveBeenCalledWith("N")
expect(mockOnChange).toHaveBeenCalledWith("e")
expect(mockOnChange).toHaveBeenCalledWith("w")
})
it("respects different input types", () => {
render(
<FormTextField
label="Email"
type="email"
value=""
onChange={mockOnChange}
/>,
)
expect(screen.getByLabelText(/Email:/i)).toHaveAttribute("type", "email")
})
})
Exercise 2
Let’s implement our FormTextField
component and have it:
- Accept
label
,onChange
,type
, andvalue
props - Create a unique ID with
useId()
- Associate the
<label>
and<input>
elements withhtmlFor
andid
props - Add the
onChange
,type
, andvalue
props to the<input>
element
Hint: The onChange
prop type can be defined as (data: string) => void
Having issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.
Solution 2
Click to see the solution
✏️ Update src/components/FormTextField/FormTextField.tsx to be:
import { useId } from "react"
const FormTextField: React.FC<{
label: string
onChange: (data: string) => void
type: string
value: string
}> = ({ label, type, value, onChange }) => {
const id = useId()
return (
<div className="form-group">
<label className="control-label" htmlFor={id}>
{label}:
</label>
<input
className="form-control"
id={id}
onChange={(event) => onChange(event.target.value)}
type={type}
value={value}
/>
</div>
)
}
export default FormTextField
Having issues with your local setup? See the solution in StackBlitz or CodeSandbox.
Objective 3: Integrate FormTextField
into RestaurantOrder
Finally we’ll update the form to incorporate the FormTextField
component so users can create and submit an order to the restaurant. We need to fill the form with input fields and handle the submit button.
Setup 3
✏️ Update src/pages/RestaurantOrder/RestaurantOrder.tsx to be:
import type { FormEvent} from "react";
import { useState } from "react"
import { useParams } from "react-router-dom"
import FormTextField from "../../components/FormTextField"
import RestaurantHeader from "../../components/RestaurantHeader"
import { useRestaurant } from "../../services/restaurant/hooks"
type OrderItems = Record<string, number>
const RestaurantOrder: React.FC = () => {
const params = useParams() as { slug: string }
const restaurant = useRestaurant(params.slug)
const [items, setItems] = useState<OrderItems>({})
if (restaurant.isPending) {
return (
<p aria-live="polite" className="loading">
Loading restaurant…
</p>
)
}
if (restaurant.error) {
return (
<p aria-live="polite" className="error">
Error loading restaurant: {restaurant.error.message}
</p>
)
}
if (!restaurant.data) {
return <p aria-live="polite">No restaurant found.</p>
}
const setItem = (itemId: string, isChecked: boolean, itemPrice: number) => {
return setItems((currentItems) => {
const updatedItems = {
...currentItems,
}
if (isChecked) {
updatedItems[itemId] = itemPrice
} else {
delete updatedItems[itemId]
}
return updatedItems
})
}
const handleSubmit = (event: FormEvent) => {
event.preventDefault()
alert("Order submitted!")
}
const selectedCount = Object.values(items).length
const subtotal = calculateTotal(items)
return (
<>
<RestaurantHeader restaurant={restaurant.data} />
<div className="order-form">
<h3>Order from {restaurant.data.name}!</h3>
<form onSubmit={(event) => handleSubmit(event)}>
{subtotal === 0 ? (
<p className="info text-error">Please choose an item.</p>
) : (
<p className="info text-success">{selectedCount} selected.</p>
)}
<h4>Lunch Menu</h4>
<ul className="list-group">
{restaurant.data.menu.lunch.map(({ name, price }) => (
<li key={name} className="list-group-item">
<label>
<input
checked={name in items}
onChange={(event) =>
setItem(name, event.target.checked, price)
}
type="checkbox"
/>
{name}
<span className="badge">{price}</span>
</label>
</li>
))}
</ul>
<h4>Dinner menu</h4>
<ul className="list-group">
{restaurant.data.menu.dinner.map(({ name, price }) => (
<li key={name} className="list-group-item">
<label>
<input
checked={name in items}
onChange={(event) =>
setItem(name, event.target.checked, price)
}
type="checkbox"
/>
{name}
<span className="badge">{price}</span>
</label>
</li>
))}
</ul>
<div className="submit">
<h4>Total: ${subtotal ? subtotal.toFixed(2) : "0.00"}</h4>
<button className="btn" type="submit">
Place My Order!
</button>
</div>
</form>
</div>
</>
)
}
function calculateTotal(items: OrderItems) {
return Object.values(items).reduce((total, itemPrice) => {
return total + itemPrice
}, 0)
}
export default RestaurantOrder
Verify 3
✏️ Update src/pages/RestaurantOrder/RestaurantOrder.test.tsx.tsx to be:
import "@testing-library/jest-dom"
import type { ReactNode } from "react"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter } from "react-router-dom"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import RestaurantOrder from "./RestaurantOrder"
import { useRestaurant } from "../../services/restaurant/hooks"
// Mock the hooks and components used in RestaurantOrder
vi.mock("../../services/restaurant/hooks", () => ({
useRestaurant: vi.fn(),
}))
vi.mock("../../components/FormTextField", () => ({
default: vi.fn(({ label, onChange, type, value }) => (
<div className="form-group">
<label htmlFor={`form-field-${label.toLowerCase()}`}>{label}</label>
<input
id={`form-field-${label.toLowerCase()}`}
data-testid={`form-field-${label.toLowerCase()}`}
className="form-control"
onChange={(event) => onChange(event.target.value)}
type={type}
value={value}
/>
</div>
)),
}))
vi.mock("../../components/RestaurantHeader", () => ({
default: vi.fn(() => (
<div data-testid="mock-restaurant-header">Mock RestaurantHeader</div>
)),
}))
// Mocking the global fetch function
const mockAlert = vi.fn()
global.alert = mockAlert
beforeEach(() => {
mockAlert.mockClear()
})
afterEach(() => {
mockAlert.mockClear()
})
const mockRestaurantData = {
data: {
_id: "1",
name: "Test Restaurant",
slug: "test-restaurant",
images: { owner: "owner.jpg" },
menu: {
lunch: [
{ name: "Lunch Item 1", price: 10 },
{ name: "Lunch Item 2", price: 15 },
],
dinner: [
{ name: "Dinner Item 1", price: 20 },
{ name: "Dinner Item 2", price: 25 },
],
},
},
isPending: false,
error: null,
}
const renderWithRouter = (
ui: ReactNode,
{ route = "/restaurants/test-restaurant" } = {},
) => {
window.history.pushState({}, "Test page", route)
return render(ui, {
wrapper: ({ children }) => (
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
),
})
}
describe("RestaurantOrder component", () => {
it("renders loading state", () => {
useRestaurant.mockReturnValue({ data: null, isPending: true, error: null })
renderWithRouter(<RestaurantOrder />)
expect(screen.getByText(/Loading restaurant…/i)).toBeInTheDocument()
})
it("renders error state", () => {
useRestaurant.mockReturnValue({
data: null,
isPending: false,
error: { message: "Error loading" },
})
renderWithRouter(<RestaurantOrder />)
expect(screen.getByText(/Error loading restaurant/i)).toBeInTheDocument()
})
it("renders no restaurant found state", () => {
useRestaurant.mockReturnValue({ data: null, isPending: false, error: null })
renderWithRouter(<RestaurantOrder />)
expect(screen.getByText(/No restaurant found/i)).toBeInTheDocument()
})
it("renders the RestaurantHeader when data is available", () => {
useRestaurant.mockReturnValue(mockRestaurantData)
renderWithRouter(<RestaurantOrder />)
expect(screen.getByTestId("mock-restaurant-header")).toBeInTheDocument()
})
it("renders the order form when restaurant data is available", () => {
useRestaurant.mockReturnValue(mockRestaurantData)
render(<RestaurantOrder />)
expect(screen.getByTestId("mock-restaurant-header")).toBeInTheDocument()
expect(screen.getByText("Order from Test Restaurant!")).toBeInTheDocument()
expect(screen.getAllByRole("checkbox").length).toBe(4) // 2 lunch + 2 dinner items
})
it("updates subtotal when menu items are selected", async () => {
useRestaurant.mockReturnValue(mockRestaurantData)
render(<RestaurantOrder />)
const checkboxes = screen.getAllByRole("checkbox")
await userEvent.click(checkboxes[0]) // Select 'Lunch Item 1' (price: 10)
expect(screen.getByText("Total: $10.00")).toBeInTheDocument()
await userEvent.click(checkboxes[2]) // Select 'Dinner Item 1' (price: 20)
expect(screen.getByText("Total: $30.00")).toBeInTheDocument()
})
it("updates form fields", async () => {
renderWithRouter(<RestaurantOrder />)
await userEvent.type(screen.getByTestId("form-field-name"), "John Doe")
expect(screen.getByTestId("form-field-name")).toHaveValue("John Doe")
await userEvent.type(
screen.getByTestId("form-field-address"),
"123 Main St",
)
expect(screen.getByTestId("form-field-address")).toHaveValue("123 Main St")
await userEvent.type(screen.getByTestId("form-field-phone"), "555-1234")
expect(screen.getByTestId("form-field-phone")).toHaveValue("555-1234")
})
it("handles form submission", async () => {
const submitSpy = vi.fn()
renderWithRouter(<RestaurantOrder />)
const submitButton = screen.getByRole("button", {
name: /Place My Order!/i,
})
const form = submitButton.closest("form")
expect(form).toBeInTheDocument() // Ensure the form is found
if (form) {
form.onsubmit = submitSpy
}
await userEvent.click(submitButton)
expect(submitSpy).toHaveBeenCalledTimes(1)
})
})
Exercise 3
- Create state variables and setters for
address
,name
, andphone
. - Use
<FormTextField>
to create input fields for these three state variables.
Having issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.
Solution 3
Click to see the solution
✏️ Update src/pages/RestaurantOrder/RestaurantOrder.tsx to be:
import type { FormEvent} from "react";
import { useState } from "react"
import { useParams } from "react-router-dom"
import FormTextField from "../../components/FormTextField"
import RestaurantHeader from "../../components/RestaurantHeader"
import { useRestaurant } from "../../services/restaurant/hooks"
type OrderItems = Record<string, number>
const RestaurantOrder: React.FC = () => {
const params = useParams() as { slug: string }
const restaurant = useRestaurant(params.slug)
const [address, setAddress] = useState<string>("")
const [items, setItems] = useState<OrderItems>({})
const [name, setName] = useState<string>("")
const [phone, setPhone] = useState<string>("")
if (restaurant.isPending) {
return (
<p aria-live="polite" className="loading">
Loading restaurant…
</p>
)
}
if (restaurant.error) {
return (
<p aria-live="polite" className="error">
Error loading restaurant: {restaurant.error.message}
</p>
)
}
if (!restaurant.data) {
return <p aria-live="polite">No restaurant found.</p>
}
const setItem = (itemId: string, isChecked: boolean, itemPrice: number) => {
return setItems((currentItems) => {
const updatedItems = {
...currentItems,
}
if (isChecked) {
updatedItems[itemId] = itemPrice
} else {
delete updatedItems[itemId]
}
return updatedItems
})
}
const handleSubmit = (event: FormEvent) => {
event.preventDefault()
alert("Order submitted!")
}
const selectedCount = Object.values(items).length
const subtotal = calculateTotal(items)
return (
<>
<RestaurantHeader restaurant={restaurant.data} />
<div className="order-form">
<h3>Order from {restaurant.data.name}!</h3>
<form onSubmit={(event) => handleSubmit(event)}>
{subtotal === 0 ? (
<p className="info text-error">Please choose an item.</p>
) : (
<p className="info text-success">{selectedCount} selected.</p>
)}
<h4>Lunch Menu</h4>
<ul className="list-group">
{restaurant.data.menu.lunch.map(({ name, price }) => (
<li key={name} className="list-group-item">
<label>
<input
checked={name in items}
onChange={(event) =>
setItem(name, event.target.checked, price)
}
type="checkbox"
/>
{name}
<span className="badge">{price}</span>
</label>
</li>
))}
</ul>
<h4>Dinner menu</h4>
<ul className="list-group">
{restaurant.data.menu.dinner.map(({ name, price }) => (
<li key={name} className="list-group-item">
<label>
<input
checked={name in items}
onChange={(event) =>
setItem(name, event.target.checked, price)
}
type="checkbox"
/>
{name}
<span className="badge">{price}</span>
</label>
</li>
))}
</ul>
<FormTextField
label="Name"
onChange={setName}
type="text"
value={name}
/>
<FormTextField
label="Address"
onChange={setAddress}
type="text"
value={address}
/>
<FormTextField
label="Phone"
onChange={setPhone}
type="tel"
value={phone}
/>
<div className="submit">
<h4>Total: ${subtotal ? subtotal.toFixed(2) : "0.00"}</h4>
<button className="btn" type="submit">
Place My Order!
</button>
</div>
</form>
</div>
</>
)
}
function calculateTotal(items: OrderItems) {
return Object.values(items).reduce((total, itemPrice) => {
return total + itemPrice
}, 0)
}
export default RestaurantOrder
Having issues with your local setup? See the solution in StackBlitz or CodeSandbox.
Next steps
Next, let’s learn how to write tests with React Testing Library.