A feature is rarely just a component. The interesting work usually starts when the click has to cross a route boundary, touch real data, and come back with a useful state.

When I first started building full-stack applications, I mentally separated the work into “frontend” and “backend”. It was a useful model for a while, but it becomes less useful as applications get more interactive.

A form that edits a record, for example, is not really a frontend feature. It has a UI, a validation boundary, a mutation, authorization, database work, a pending state, an error state, and usually a revalidation step. The component is just the visible end of the feature.

01UIinput / pending / error
02Routeloader / action
03Servervalidation / auth
04Dataquery / transaction

Start from the boundary

I prefer to decide where the feature enters the application before deciding how to structure the component. In a React Router application, that often means starting with the route module: what data does this screen need, and what mutation does it own?

export async function loader() {
  return getProjectForUser()
}

export async function action({ request }) {
  const form = await request.formData()
  return updateProject(form)
}

That doesn’t mean every route becomes a miniature backend. It means the ownership is obvious. The route owns the transition, while the UI owns how that transition is represented.

When the boundary is clear, the component usually gets smaller.

Keep the UI close to the state it represents

Pending and error states are not decoration. They are part of the feature contract. I would rather have a button that knows exactly when its mutation is pending than build a global loading system that tries to infer what is happening.

The same idea applies to optimistic updates. Use them where the result is predictable and reversible. Otherwise, the extra complexity can make a simple CRUD feature harder to reason about than the original request.

The boring version is usually the good version

After enough projects, I find myself choosing fewer abstractions. A loader, an action, a schema, a query, and a small component are often enough. The architecture becomes easier to debug because the path from click to database row is visible in the code.

That is the part of full-stack development I enjoy most: not making each layer clever, but making the whole path understandable.