Use when the question is how interface should be BUILT — component boundaries, state that belongs in the URL vs the component, loading/empty/error states, forms, optimistic updates, build vs adopt. NOT the visual system (`design`), NOT framework mechanics (`react`/`nextjs`).
npx skills add https://github.com/ericrisco/rsc-harness --skill ui-engineering
There is a layer that neither the design skill nor the framework skill owns. The design says what
the screen should be. The framework says how its API works. **In between sits every decision that
makes an interface either durable or a maintenance tax** — and it is where most interface work
actually goes wrong.
Nothing here is framework-specific. These decisions are the same in React, Vue, Svelte, SwiftUI and
plain HTML; only the syntax moves.
State goes to the outermost place that still needs it, and no further. Almost every interface
tangle is that rule broken in one of two directions: state hoarded in a component that others need,
or state hoisted into a global store that only one component ever reads.
| Where the state lives | When that is right |
| --- | --- |
| The URL | Anything a user would bookmark, share, or expect the back button to restore: filters, tabs, pagination, the open item |
| The server | Anything that outlives the session or belongs to more than one user |
| A shared parent | Two siblings genuinely need it, and it dies with the screen |
| The component | Nobody else reads it: hover, focus, an open menu, an in-flight draft |
The most common and most expensive mistake is the first row: state that belongs in the URL kept in a
component. It looks fine until someone reloads, shares a link, or presses back — and then the
interface silently loses what the user did.
An interface is not finished when it renders data. It is finished when it renders all five:
| State | The question it answers | The usual failure |
| --- | --- | --- |
| Loading | "Is it working?" | A spinner where the shape is already known — use the shape |
| Empty | "Is it broken, or is there nothing?" | Blank. It must say which, and what to do next |
| Error | "What now?" | A message with no action. Every denial carries its own way out |
| Partial | "Some of it failed" | Ignored entirely; the screen shows a half-truth |
| Success | "It worked" | Silence, so the user does it twice |
The empty state is the one that gets skipped, and it is the first state a new user ever sees.
A component earns its existence by hiding a decision, not by being reused. Splitting for reuse
alone produces the worst interfaces: a wrapper around a wrapper, each passing props through, none
owning anything.
Draw the boundary where:
Do not draw a boundary because a file is long. Long is not a defect; a file that does two
unrelated things is, and it is usually short.
Forms are where interfaces most often insult people, and the rules are unglamorous:
the third character is scolding them for not having finished.
theirs.
button with no reason is a dead end.
Show the result before the server confirms only when you can honestly undo it. That means: the
operation is idempotent, the failure is rare, and reverting it is visible and comprehensible.
Optimism on a destructive or an expensive action is a lie you will have to retract in front of the
user. Deleting, paying, sending — those wait.
Adopting a component costs bundle size, an API you did not design, and an upgrade you will owe
later. Building costs the accessibility work you will get wrong.
The split is not about difficulty, it is about who is the authority on the behaviour:
trapping, combobox semantics, date handling, virtualisation.
you 60% and you fight the remaining 40% forever.
| If you're about to… | Reality / Fix |
| --- | --- |
| Put a filter or tab selection in component state | It belongs in the URL. Reload, share and back are part of the interface. |
| Add a global store because passing props feels tedious | Tedious is not broken. Global state that one component reads is a leak with ceremony. |
| Ship a screen with only the loaded state | Loading, empty, error and partial are not extras. The empty one is what a new user sees first. |
| Split a component because the file got long | Length is not the defect. Two unrelated responsibilities are, and they are usually short. |
| Show a delete as done before the server confirms | Optimism on a destructive action is a lie you will retract in public. |
| Reach here for how the framework's hooks work | That is ../react/SKILL.md or ../nextjs/SKILL.md. This skill decides what to build; those decide how to express it. |
../design/SKILL.md — the visual system these decisions serve.../motion-craft/SKILL.md — how the pieces here move once they exist.../design-loop/SKILL.md — the graded critique that judges the result.../accessibility/SKILL.md — the ground under focus, semantics and forms.Take ericrisco/ui-engineering from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.