A lot of React apps start life in an AI builder now. Auth works, routes work, customer records load. Then someone looks at the screens and says the UI needs design work.
When you change a screen, you can quietly change how people get their work done on it. A row that used to open the right record now opens the wrong one. A filter that used to stick now resets. Nothing crashes, so nobody notices until a user complains.
So before touching the layout, write down what the screen must keep doing. Which record an action opens. Which filters survive navigation. What happens while data loads or fails. Then check every design decision against that list.
To show how this works, I built a small React and TypeScript feedback inbox with made-up customer requests. The flow is narrow on purpose: filter requests, open one, read it, and go back to the filtered list.
1. Write the behavior down before you move a pixel
Start with a short scenario that a designer and a developer can both read:
- Choose Reporting in the product-area filter.
- Open FR-108: Export feedback to CSV.
- Confirm that the detail shows Northline, the customer attached to FR-108.
- Choose Back to requests.
- Confirm that Reporting is still selected and keyboard focus returns to the request button.
Now every design idea has a test to pass. Moving the status badge is presentation, so go ahead. Opening a different record, clearing the filter, or changing what "Planned" means is behavior, and it needs a conversation first.
In a real app, write down the existing route, permission checks, API contracts and action callbacks next to the scenario. A UI change can usually leave those alone. If a new design needs a new endpoint or a different permission rule, the task just grew, so scope it that way.
Our example runs on local records. A real product also needs checks against its real data and auth rules.
2. Let the row show what people scan for
First question: what does someone need to see before they open a request?
Selection and status mean different things, so they need different looks. An open request can still be New, Planned or In review. If selection looked like a status, a click would read like a status change.
This is the whole row component:
export function RequestRow({
request,
selected,
onOpen,
}: {
request: FeedbackRequest;
selected: boolean;
onOpen: (id: string, button: HTMLButtonElement) => void;
}) {
return (
<li>
<button
type="button"
className="request-row"
aria-expanded={selected}
aria-controls={selected ? "request-detail" : undefined}
onClick={(event) => onOpen(request.id, event.currentTarget)}
>
<span className="request-copy">
<strong className="request-title">{request.title}</strong>
<span className="request-meta">
{request.company} ยท {request.id}
</span>
</span>
<span className="status" data-status={request.status}>
{request.status}
</span>
</button>
</li>
);
}
FeedbackRequest holds the record's ID, title, customer, product area, status and message. The row gets a record and an open callback. It never changes the status.
Why a native button? You get keyboard support for free. Because the button reveals related content, aria-expanded tells assistive tech whether that content is open. When the detail opens, its heading gets focus, and Back to requests sends focus back to the row you came from. The WAI-ARIA disclosure pattern explains the expanded-state part.
This works because our detail panel is read-only. If your rows hold checkboxes, menus or inline actions, you need a different structure, because each control needs its own valid target.
3. Store the ID, not the record
The inbox keeps three pieces of UI state:
const [query, setQuery] = useState("");
const [area, setArea] = useState("All");
const [selectedId, setSelectedId] = useState<string | null>(null);
const visible = requests.filter((request) => {
const matchesArea = area === "All" || request.area === area;
const text = `${request.title} ${request.company} ${request.id}`
.toLowerCase();
return matchesArea && text.includes(query.trim().toLowerCase());
});
const selected = loadState === "ready"
? visible.find((request) => request.id === selectedId)
: undefined;
Why derive selected instead of storing it? A second copy of the record goes stale. If the parent sends an updated status for the same ID, the list and the detail both show it, because both read from the same records. React's state-structure guidance recommends this selected-ID approach.
Search and the area filter work together. Changing either one closes the detail panel here. That's a deliberate product rule: the open record always belongs to the visible results. If your product keeps the detail open while filtering, pick a different rule and write a test for it.
Render rows with key={request.id}. And during a redesign, watch the component type, position and keys around stateful parts of the screen. React ties state to a component's place in the render tree, so wrapping a stateful component in a new container can quietly reset it. More on that in preserving and resetting state.
In other words, keeping the same API calls isn't enough. The component tree matters too.
4. One state, two layouts
On a wide screen, the list and detail sit side by side. On a narrow screen, the detail takes over, and Back to requests returns to the list.
The inbox component owns the filters and selected ID in both layouts. CSS only changes what's visible. There are no separate desktop and mobile copies of state to drift apart:
.inbox-layout.has-detail {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
}
@media (max-width: 760px) {
.inbox-layout.has-detail {
grid-template-columns: minmax(0, 1fr);
}
.has-detail .inbox {
display: none;
}
}
760px is simply where this layout stops fitting. Check where yours does. Long titles, translated labels, enlarged text and an open detail panel will find problems that a short desktop fixture never shows.
We also gave flex and grid children min-width: 0, let long text wrap, and kept the Back button in the normal document flow, so narrow layouts behave predictably.
5. Decide once, reuse everywhere
Before you spread the design to more pages, agree on a few shared decisions: text hierarchy, spacing, focus style, selection style and status labels. Map them to the tokens and components the product already has.
The example names its selection colors:
:root {
--selection-bg: #eef3ff;
--selection-border: #294fc2;
}
.request-row[aria-expanded="true"] {
background: var(--selection-bg);
border-inline-start-color: var(--selection-border);
}
.status[data-status="Planned"] {
color: #594013;
background: #fff2d7;
}
If your design system already has fitting tokens, use them. The new screen stays consistent with its neighbors, and later changes are easier to review.
Design the loading, empty and error states alongside the main screen, not after it. In this inbox, loading has its own message. An empty filter result offers Clear filters. A failed request offers Retry and keeps your search and filter.
Each state answers a different question. Is it working? Does anything match? Did the data load? So each one needs its own next step.
6. Test behavior, not only pixels
A screenshot catches clipped text, uneven spacing or an unreadable badge. It can't tell you that a row opens the right customer or that Retry kept your filters.
So the browser check runs the scenario from step 1 directly. These lines come from its Playwright script:
await page.getByLabel("Product area").selectOption("Reporting");
await page.locator(".request-row").first().click();
assert.equal(
await page.locator("#request-detail h2").textContent(),
"Export feedback to CSV"
);
assert((await page.locator("#request-detail").textContent())
.includes("Northline"));
await page.getByRole("button", { name: "Back to requests" }).click();
assert.equal(
await page.getByLabel("Product area").inputValue(),
"Reporting"
);
The full script also covers keyboard activation and focus return, combined filters, a record update reaching the open detail, narrow layouts, loading, empty results and retry. All 14 checks passed in a local Chromium run, and the output logs the browser version and each check.
For screenshot comparisons, use fixed records, a fixed viewport and the same rendering environment every time. Look at the first image yourself before you accept it as a baseline. Playwright's visual-comparison docs cover baselines and environment differences.
These checks cover local UI behavior only. Real API integration, authorization, real devices and a screen-reader session are separate work.
7. Hand off decisions, not only files
A good handoff lists the changed components, why you made each design decision, the acceptance scenarios and the checks you ran. It also flags anything that needs another owner, like a new response field or a permission rule.
This matters most when design and frontend share responsibility for a working product. At UITOP we ran into this on LogiCore, a logistics platform where we handled both the design and the React/TypeScript development, including shipment tracking and warehouse inventory. Interface decisions there had to match how the operational data behaved, or the screens looked right and worked wrong.
So for a React UI refinement, start with one important workflow. Make its component and state decisions easy to review, run the agreed checks, and only then roll the patterns out across the product.
Questions
Can I keep a React redesign limited to the UI layer?
Yes, if you scope it to presentation components, styles and their existing callbacks. Write down the routes, record IDs, API contracts, permissions and state that must keep working. Then review the changed files and run the affected workflow. Telling an AI agent "UI only" doesn't verify the code it writes.
Should I define design tokens and shared components before updating the pages?
Look at what the app already uses first. Settle shared decisions on one representative workflow, including its error and narrow-screen states, then reuse them. Building a big new token system before checking the existing components can turn a small refinement into a migration.
How should a team structure large AI-generated designs for implementation?
Separate shared styles and components from page-specific content. Give each task one bounded screen or workflow, with its dependencies and acceptance scenarios. Check the exports against your real React architecture, because a large generated HTML file doesn't define component boundaries or data ownership.
How can I use screenshot comparison to review a React UI redesign?
Capture stable fixtures at agreed viewports and compare them against baselines a person has reviewed, in a consistent environment. Add interaction checks for record identity, filters, focus and actions. A screenshot can match perfectly while the workflow behind it is broken.
How can new features follow the existing brand guidelines?
Turn the guidelines into the tokens, component variants and content rules your codebase uses. Review one representative feature against those decisions before extending it. A reference image gives useful context, but the code needs explicit rules for typography, color, spacing and states.
Should I hire a UI/UX designer, a frontend developer, or someone who handles both?
Let the unresolved work decide. Flow and hierarchy decisions need design ownership. Component integration and state behavior need frontend ownership. When both are in scope, give the team shared acceptance criteria and one clear implementation reviewer. UITOP combines product design and frontend development, and we scope that work around the workflow and codebase that need attention.
Which workflow in your React app needs a design change, and which behavior has to stay the same?

Top comments (0)