Give each component one reason to change
I think about component boundaries through change. If the copy changes, the layout should not need a rewrite. If the interaction changes, a data model should not become a pile of event handlers. A component does not need to be tiny, but its responsibilities should be easy to name.
This is why I avoid putting filtering, API calls, keyboard behavior and visual markup into one reusable card. The first version may be fast, but every new requirement then touches the same crowded file.
- Keep data shaping near the feature
- Separate repeated UI from feature-specific decisions
- Prefer explicit props over hidden global behavior
Make states visible in the types
A component usually has more states than its happy path: loading, empty, error, disabled and partially filled. TypeScript is useful when it makes those states visible before the browser does. A prop type should describe what the component can actually render, not just what the first call site happened to pass.
I prefer discriminated unions when states have different data requirements. That prevents a component from receiving an impossible combination such as `status: success` with no result, and it gives the render logic a clear shape.
- Model loading and error states
- Use narrow types for component contracts
- Avoid optional props that hide impossible states
Keep interaction boundaries deliberate
Client-side state is not free. It adds code, hydration work and another place for behavior to drift from the server-rendered page. I keep state close to the interaction that owns it and avoid making a whole route interactive because one button needs a click handler.
Accessibility is part of that boundary. A custom visual control still needs keyboard behavior, a name and a meaningful disabled state. Native elements are often the most maintainable abstraction available.
- Use native controls when they fit
- Keep state local to the interaction
- Test keyboard and focus behavior
Avoid abstractions that arrive too early
When two components look similar, I first check whether they change for the same reason. Sharing every line can make the code less readable if the components only happen to be similar today. A small amount of duplication is often cheaper than a generic component with a dozen flags.
I extract an abstraction when it removes a real decision or makes a repeated behavior safer. The test is practical: can a future change be made once without making unrelated screens harder to understand?
- Extract repeated behavior, not just repeated markup
- Keep variant names meaningful
- Delete unused abstraction paths
Test behavior instead of implementation
The useful test is not that a component calls a particular hook. It is that a visitor can open the menu, submit the form, see an error and recover. Those tests survive refactors because they describe the contract at the boundary.
For visual pieces, a focused browser check and a few states often give more confidence than a large snapshot. The component remains easy to change because the test protects what matters, not its current DOM arrangement.