Keep Your Ownership Tree Shallow
UI code becomes hard to change when product decisions are buried across deeply nested components. Keep feature ownership shallow, make decisions visible, and enforce it with ESLint.
Disclaimer: This post is based on a talk I gave at the Munich TypeScript Meetup and WeAreDevelopers World Congress.
React encourages us to think about our UI as a tree. We look at a screen, identify its parts, and turn those parts into components that render each other, rebuilding the UI tree.
That sounds reasonable. Yet it often leaves us with code that is locally tidy and globally difficult to understand. The problem is not the number of components. The problem is where decisions live.
A Screen Is More Than Its Screenshot
Consider the people screen from Tilly, my open source relationship journal:

Screen├── Title├── Toolbar│ ├── Search│ ├── Filter│ └── New Person Button└── List └── Person RowThat is the UI tree we can see. But the actual screen has more states.
There might be no people yet. A search might have no results. The active filter might be empty while deleted people still exist. Rows might behave differently depending on whether a person is active or deleted.


And that is still only the visible state. List items support swipe gestures. Actions open dialogs. Each interaction adds more behavior that does not show up in a static UI tree.


These are not implementation details. They are product decisions.
Now imagine the code follows the visible tree:
export function PeopleScreen() { return ( <PeoplePage> <PeopleHeader /> <PeopleListArea /> </PeoplePage> )}This looks clean. But what does PeopleListArea do?
function PeopleListArea() { if (!hasPeople) return <NoPeopleState /> if (!hasResults) return <NoResultsState />
return <PeopleRows />}And what does PeopleRows do?
function PeopleRows() { return people.map(person => person.deletedAt ? ( <DeletedPersonRow person={person} /> ) : ( <ActivePersonRow person={person} /> ), )}Every component does one thing. Every file is small. And understanding the screen requires chasing its logic through the entire tree.

The code tells us how the UI is nested. It hides how the screen behaves.
The UI Tree Is Not the Ownership Tree
I previously wrote about the two trees in React from a rendering perspective. A component can appear below another component in the UI without being created by it.
The same distinction matters for decision architecture.
The UI tree describes what is nested on screen. The ownership tree describes where decisions live.
Those trees do not need the same shape.
A generic list can own scrolling, virtualization and item measurement without deciding which empty state to show or how a person should look. Those decisions belong to its caller.
React gives us composition patterns to separate UI and ownership trees: children, slots, render props and callbacks.
Lift Product Decisions Up
Here is the same screen with those decisions visible. This example is simplified from the actual PeopleScreen:
function PeopleScreen() { return ( <VirtualizedList items={people} staticHeader={ <> <PeoplePageTitle /> <PeopleToolbar> <PeopleSearch trailing={<ListFilter />} /> <NewPerson /> </PeopleToolbar> </> } fallback={ !hasPeople ? ( <NoPeopleState /> ) : statusFilter === "deleted" && !hasResults ? ( <NoDeletedPeopleState /> ) : didSearch && !hasResults ? ( <NoSearchResultsState /> ) : ( <NoActivePeopleState /> ) } renderItem={({ person }) => person.deletedAt ? ( <DeletedPerson person={person} /> ) : ( <ActivePerson person={person} /> ) } /> )}This component is longer. It is also honest.
If a screen has four different empty states, I want the screen component to show me those four states. If active and deleted people render differently, I want to see that branch in the same component.
The UI tree can stay deeply nested while the ownership tree becomes dramatically flatter. I would rather understand one larger component and a few focused components than jump through many small components to discover the same behavior.
The contract of VirtualizedList stays generic:
type VirtualizedListProps<T> = { items: T[] staticHeader?: React.ReactNode fallback?: React.ReactNode renderItem: (item: T) => React.ReactNode}It owns mechanics. Its caller owns the product decisions.
My Target Depth Is Three
Inside feature folders, I aim for this ownership depth:
screen -> widget -> partA screen can compose widgets and parts. A widget can compose parts. A part does not compose more feature UI.
This is a rule of thumb, not a claim that every component in the codebase should be shallow. Shared dialog primitives, form controls, hooks and infrastructure should be as composable as they need to be.
The rule applies where product decisions live: inside features.
features/└── people/ ├── screens/ ├── widgets/ └── parts/
shared/├── ui/└── lib/This structure gives each component a role. More importantly, it gives the architecture a boundary we can enforce. You can explore the complete people feature folder to see how screens, widgets, parts, hooks and lib work together in practice.
Context-Dense Code
Keeping the ownership tree shallow already helps humans. It matters even more when agents read and write the code.
Neither humans nor agents read every file before making a change. A component that contains the screen’s important states and branches provides better context. There is less guessing, less navigation and less chance of changing behavior in the wrong place.
What’s good for humans is good for agents too.
Use ESLint Rules, Not Prompts
A convention in a document is easy to forget. An instruction in an agent prompt is easy to ignore.
If the architecture matters, make invalid code fail.
For feature code, the import rules can express the ownership tree:
route -> screenscreen -> widget | partwidget -> partpart -> no other feature UI
feature -> shared ui | shared libThat makes the difference between allowed and forbidden composition concrete:
forbidden:parts/person-row -> widgets/contact-menu
allowed:screens/people-screen -> widgets/contact-menuwidgets/contact-menu -> parts/menu-itemparts/person-row -> shared/ui/avatarNot vibes. Not taste. A rule.
Import constraints are not a perfect model of every possible render path. They do not need to be. They are simple, fast and precise enough to protect the architecture.
In Tilly, this is a small custom ESLint plugin with several rules working together. For example, no-feature-part-composition keeps parts from rendering other parts, while only-screens-and-widgets-may-import-parts limits where parts can be composed. The tests are executable examples of the architecture. The rules are enabled in the project’s eslint.config.js.
I started with the outcome I wanted: a flat ownership tree. Then, in collaboration with my agent, I designed the feature folder pattern around what ESLint can statically analyze. Folder names, imports and JSX composition give a linter concrete signals to work with.
Then I wrote examples of imports and compositions that should pass or fail. Those examples became tests. They gave me confidence in the agent’s implementation without requiring me to understand every detail of the rule.
When I found edge cases during code review, I added them to the test setup. The rule improved together with the codebase.
I believe the same approach works for many style guides: decide what outcome you want, find a shape that static analysis can recognize, and turn examples into tests.
The linter closes the feedback loop for agents. When an agent introduces a forbidden dependency, it gets an immediate, actionable error. It can inspect nearby examples and recover without relying on a prompt to remember the entire architecture.
Refactor the Pattern, Then Block It
When I find code that is difficult to understand, I can refactor it. But the more important question is why that shape was possible in the first place.
Can the folder structure make the roles clear? Can imports encode the intended direction? Can a linter prevent the old pattern from returning?
React lets us create any UI tree. Our job is to make the screen’s product decisions easy to find in its implementation.
Keep product decisions visible. Keep feature ownership shallow. And if the architecture matters, enforce it.