A view shows what the state already holds and forwards what the customer does. On the Sean Dinwiddie’s Webmastery team, a view decides nothing a scenario tests: selectors shape what it reads, the endpoint’s hook carries the request, and the view’s own test reads the screen the way the customer does.
This lesson builds the checkout that places the order, from the cart in From scenario to slice and the endpoint in Endpoints at the boundary.
1. What a view may decide
Every rule in the checkout already has an owner:
- the cart’s lines and total belong to the slice and its selectors;
- the order, and whether the API accepted it, belong to RTK Query;
- what is on the screen for a moment, such as a note being typed or a panel left open, belongs to the component’s own
useState.
What is left for the view is small: read, show, and forward an event. A view that sums a line, formats a price or empties the cart after a click holds a rule no scenario can reach without a browser.
The lecture Redux Standard Patterns and Functional Programming shows a view that subscribes to the smallest values it renders and dispatches one event.
2. A view model is a selector
The checkout shows each line the way the customer reads it: a name, a quantity and a price in dollars, then the total. That shape is a view model, and like the total it is derived, never stored.
One selector builds it from the slice’s own selectors, and a second builds what the checkout sends, product and quantity only, because the API prices the order itself:
// features/checkout/checkoutSelectors.ts
import { createSelector } from '@reduxjs/toolkit'
import { selectLines, selectTotalCents } from '../cart/cartSlice'
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
const dollars = (cents: number) => usd.format(cents / 100)
// What the checkout shows: every line as the customer reads it, and the total.
export const selectCheckout = createSelector([selectLines, selectTotalCents], (lines, totalCents) => ({
lines: lines.map((line) => ({
id: line.productId,
name: line.name,
quantity: line.quantity,
price: dollars(line.priceCents * line.quantity),
})),
total: dollars(totalCents),
canPlace: lines.length > 0,
}))
// What the checkout sends: product and quantity only; the API sets the prices.
export const selectNewOrder = createSelector([selectLines], (lines) => ({
lines: lines.map(({ productId, quantity }) => ({ productId, quantity })),
}))
createSelector returns the same object until the lines change, so the view re-renders only when there is something new to show. React Redux checks this in development: a selector that returns a new object for the same state logs a warning.
Both selectors are tested like the slice’s, with plain state and no view. The lecture Modern Redux Architecture Patterns makes simple selectors a feature’s public read API, and memoizes where reference stability matters.
3. The view reads, shows and forwards
The store’s hooks are typed once, so no view imports the store:
// app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
The view that places the order reads the two selectors, takes the mutation hook the endpoint generated, and renders:
// features/checkout/PlaceOrder.tsx
import { useAppSelector } from '../../app/hooks'
import { usePlaceOrderMutation } from '../orders/ordersApi'
import { selectCheckout, selectNewOrder } from './checkoutSelectors'
import { orderNotice } from './orderNotice'
export function PlaceOrder({ tenantId }: { tenantId: string }) {
const checkout = useAppSelector(selectCheckout)
const newOrder = useAppSelector(selectNewOrder)
const [placeOrder, request] = usePlaceOrderMutation()
return (
<section aria-labelledby="checkout-title">
<h2 id="checkout-title">Your order</h2>
<ul>
{checkout.lines.map((line) => (
<li key={line.id}>
{line.name}, {line.quantity}: {line.price}
</li>
))}
</ul>
<p>Total: {checkout.total}</p>
<button
type="button"
disabled={!checkout.canPlace || request.isLoading}
onClick={() => placeOrder({ tenantId, newOrder })}
>
Place order
</button>
<p role="status">{orderNotice(request)}</p>
</section>
)
}
The click calls placeOrder and nothing more. The view doesn’t await the answer or empty the cart: the slice empties it on placeOrder’s matchFulfilled, and the hook’s state says whether the request is under way, done or refused.
The button stays disabled while the request runs, so an order can’t go twice. The status line is on the page from the start, so a screen reader announces the change when its text arrives. What it says comes from a pure function of the request, tested with no view at all:
// features/checkout/orderNotice.ts
import type { QueryStatus } from '@reduxjs/toolkit/query'
type OrderRequest = { status: QueryStatus; error?: unknown }
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null
// The API answers a refusal as data, such as 422 with { reason: 'card-declined' }.
const reasonOf = (error: unknown): unknown =>
isRecord(error) && isRecord(error.data) ? error.data.reason : undefined
// What the customer can do about each refusal, keyed by the API's reason.
const REFUSALS = new Map<unknown, string>([
['card-declined', 'Your card was declined. Please choose another card.'],
['payments-unavailable', 'Payment is unavailable for a moment, and your card wasn’t charged. Please try again shortly.'],
])
// Any other failure, including a dropped connection, can't say whether the order was recorded.
const NOT_CONFIRMED = 'The order wasn’t confirmed, and your cart is as it was.'
// One notice for every state a request can be in: leave one out and this fails to compile.
const NOTICES: Record<QueryStatus, (error: unknown) => string> = {
uninitialized: () => '',
pending: () => '',
fulfilled: () => 'Order placed.',
rejected: (error) => REFUSALS.get(reasonOf(error)) ?? NOT_CONFIRMED,
}
export const orderNotice = ({ status, error }: OrderRequest): string => NOTICES[status](error)
The function chooses by key rather than through a chain of conditions, as the lecture Functional Composition advises. Two lookups do all the work:
- the request’s
statuspicks one entry from a table that has to name all four states, so a state left out fails to compile; - a refusal’s reason picks its words from a
Map, which, unlike an object literal, holds no inherited keys such asconstructor.
A reason the map doesn’t hold gets the notice that is always true.
status picks one entry from the table, and a refusal’s reason picks its words from the map. The violet line is the declined card; any reason the map doesn’t hold gets the notice that is always true.A declined card comes back as data, a refusal with its reason, as Creating BDD scenarios for real-world cases asks, and the customer is asked for another card; a payment provider that is down comes back as a 503 with a reason of its own, and the customer is asked to try again.
Any other failure says the order wasn’t confirmed and the cart is as it was, because both are true: when the connection drops, the client can’t know whether the API recorded the order. The words follow the API’s reason, never the status alone, because a 503 page from a proxy in front of the API says nothing about the order.
The function is tested on its own, branch by branch, including the failures no scenario names:
// features/checkout/orderNotice.test.ts
import { QueryStatus } from '@reduxjs/toolkit/query'
import { describe, expect, it } from 'vitest'
import { orderNotice } from './orderNotice'
const rejected = (error: unknown) => orderNotice({ status: QueryStatus.rejected, error })
describe('orderNotice', () => {
it('says nothing before the order is placed, or while it is on its way', () => {
expect(orderNotice({ status: QueryStatus.uninitialized })).toBe('')
expect(orderNotice({ status: QueryStatus.pending })).toBe('')
})
it('says the order is placed when the API accepts it', () => {
expect(orderNotice({ status: QueryStatus.fulfilled })).toBe('Order placed.')
})
it('asks for another card when the API refuses the card', () => {
expect(rejected({ status: 422, data: { reason: 'card-declined' } })).toBe(
'Your card was declined. Please choose another card.',
)
})
it('asks her to try again when the payment provider is down', () => {
expect(rejected({ status: 503, data: { reason: 'payments-unavailable' } })).toBe(
'Payment is unavailable for a moment, and your card wasn’t charged. Please try again shortly.',
)
})
it('says the order wasn’t confirmed for every other failure', () => {
const others = [
{ status: 'FETCH_ERROR', error: 'TypeError: Failed to fetch' }, // the connection dropped
{ status: 422, data: { reason: 'unknown-product' } }, // a refusal with no words of its own here
{ status: 'PARSING_ERROR', originalStatus: 503, data: '<html>Service Unavailable</html>', error: 'SyntaxError' }, // a proxy's page, not the API's
undefined,
]
for (const error of others) {
expect(rejected(error)).toBe('The order wasn’t confirmed, and your cart is as it was.')
}
})
})
The lecture Redux Toolkit and RTK Query Best Practices reads a mutation’s state the same way, and the review checklist in Functional Programming Maintenance Strategy keeps ternaries to leaf values, like the one in reasonOf.
4. Read only what the view shows
The order history reads the list the endpoint caches. selectFromResult narrows the hook to the fields the view shows, so the view re-renders when they change and not for the rest of the request’s state:
// features/orders/OrderHistory.tsx
import type { Order } from './ordersApi.generated'
import { useListOrdersQuery } from './ordersApi'
const NO_ORDERS: Order[] = []
export function OrderHistory({ tenantId }: { tenantId: string }) {
const { orders, isLoading, isError } = useListOrdersQuery(
{ tenantId },
{
selectFromResult: ({ data, isLoading, isError }) => ({
orders: data ?? NO_ORDERS,
isLoading,
isError,
}),
},
)
if (isLoading) return <p>Loading your orders…</p>
if (isError) return <p>Your orders couldn’t be loaded.</p>
return (
<ul aria-label="Past orders">
{orders.map((order) => (
<li key={order.id}>Order {order.id}</li>
))}
</ul>
)
}
RTK Query compares what selectFromResult returns field by field. A fallback written as data ?? [] is a new array each time the callback runs, so it reads as a change and costs a render for nothing; NO_ORDERS, declared once outside the component, keeps one reference.
isLoading is true only for the first load, so after an order is placed the list stays on the screen while its tag refetches it. The lecture Modern Redux Architecture Patterns warns against the copied array for the same reason.
5. The test reads the screen
The view’s test renders it in a real store, with the cart slice, the API root’s reducer and middleware, and a stand-in for the server behind fetch, as the endpoint’s test does.
It finds what the customer finds, by role and by visible text, clicks through Testing Library’s user-event, and never mocks a hook or reads the component’s state. Its two tests carry the names Module 2 gave the scenarios:
// features/checkout/PlaceOrder.test.tsx
import { configureStore } from '@reduxjs/toolkit'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Provider } from 'react-redux'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../../app/api'
import { cartSlice, itemAdded } from '../cart/cartSlice'
import { PlaceOrder } from './PlaceOrder'
const tenantId = '018ade1a-7830-7981-b23f-f3a7f7b8f09f'
const widgetA = { productId: 'widget-a', name: 'Widget A', priceCents: 2500 }
const placed = { id: 'order-1', lines: [{ ...widgetA, quantity: 1 }], totalCents: 2500 }
// Given Ada's cart holds "Widget A" at $25, with the server's answer set by the test
const renderCheckout = (answer: () => Response) => {
vi.stubGlobal('fetch', vi.fn(async () => answer()))
const store = configureStore({
reducer: {
[cartSlice.reducerPath]: cartSlice.reducer,
[api.reducerPath]: api.reducer,
session: () => ({ token: 'test-token' }),
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware),
})
store.dispatch(itemAdded(widgetA))
render(
<Provider store={store}>
<PlaceOrder tenantId={tenantId} />
</Provider>,
)
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('Feature: Checkout', () => {
it('Scenario: Placing the order', async () => {
const user = userEvent.setup()
renderCheckout(() => Response.json(placed, { status: 201 }))
expect(screen.getByRole('listitem')).toHaveTextContent('Widget A, 1: $25.00')
expect(screen.getByText('Total: $25.00')).toBeInTheDocument()
// When she places the order
await user.click(screen.getByRole('button', { name: 'Place order' }))
// Then she sees the order placed, and her cart is empty
expect(await screen.findByText('Order placed.')).toBeInTheDocument()
expect(screen.queryByRole('listitem')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Place order' })).toBeDisabled()
})
it('Scenario: Placing the order with a declined card', async () => {
const user = userEvent.setup()
// And her card will be declined
renderCheckout(() => Response.json({ reason: 'card-declined' }, { status: 422 }))
// When she places the order
await user.click(screen.getByRole('button', { name: 'Place order' }))
// Then her cart still holds "Widget A", and she is asked for another card
expect(await screen.findByText(/choose another card/)).toBeInTheDocument()
expect(screen.getByRole('listitem')).toHaveTextContent('Widget A')
})
})
Vitest runs the file in jsdom (environment: 'jsdom' in its config), with jest-dom’s matchers loaded once in the setup file (import '@testing-library/jest-dom/vitest').
Without Vitest’s globals, Testing Library can’t clean up between tests on its own, so the file calls cleanup itself; otherwise the second test finds two checkouts on the page. The lecture Redux Toolkit and RTK Query Best Practices tests components the same way, through what they show rather than how they work.
One scenario now runs through the whole chain, and each owner proves its own part under the same name: the API records the order in the Hspec test, the endpoint refetches the history, the slice empties the cart, and the view shows the customer the order placed and the cart empty. The view holds none of those rules, which is why it stays small enough to read on one screen.