Easily avoid frontend component directories with high cognitive load
Since following advice on the internet long ago, I adopted a certain component structure that “just works.”
Scenario
Let’s first imagine a simplified frontend app directory structure, taking some inspiration from Next.js.
public/
some-image.jpg
pages/
index.tsx
components/
Heading.tsx
Logo.tsx
Layout.tsx
BoxContainer.tsx
Footer.tsx
The Problem
The simple application structure above gives little insight into how these components interact.
For example, you might guess that Layout.tsx imports Footer.tsx and Header.tsx, which in turn might import BoxContainer.tsx. But this is not clear from the file structure alone.
What’s worse, as your application grows, the list of components will become increasingly more difficult to deduce how they’re connected.
The Naïve Approach: Flat Components Structure
A common first thought might be to organize components into semantically correct directories.
Here’s a typical result of this approach:
public/
some-image.jpg
pages/
index.tsx
components/
layout/
Layout.tsx
Heading.tsx
Footer.tsx
common/
Heading.tsx
BoxContainer.tsx
Problem #1: good names are difficult to scale
Naming things is hard. As a developer, you try to create good names and classifications for each directory, like containers, headings, etcetera.
The problem is that you need to think of even more classifications for the directories, not just the component names.
You will often be tempted to say, “You know what, I’ll just move this to the common directory.” Having common directories is an anti-pattern to what you’re trying to achieve, but with this structure, it’s simply too easy to be drawn into it.
And, when your application gets large enough, you might have to start considering creating another level of directories to keep things organized.
This requires even more name creation, increasing the cognitive load for repository users. Ultimately, this approach doesn’t scale well.
Problem #2: increased cognitive load of directory names
Before, the ones trying to navigate the repo tried to initially understand what each component does by its name and how they relate to each other.
Now they also have to understand the directory names you created, which could potentially confuse them further if the names don’t semantically add up to a whole.
The Better Approach: Component Trees Pattern
With this approach, instead of going out of your way to classify groups of components with different names, your focus is to have well-named components that implicitly explain what they consist of.
Component import rules
- Can import upwards, except its own parent
- Can import siblings
- Cannot import sibling’s components
- Cannot import its parent
public/
some-image.jpg
pages/
index.tsx
components/
Layout/
components/
Heading/
components/
Logo.tsx
Menu.tsx
Heading.tsx
CopyrightIcon.tsx
Footer.tsx
Layout.tsx
BoxContainer.tsx
Let’s show the contents of Footer.tsx and use that as an example using the rules I listed above:
// components/Layout/components/Footer.tsx
// Can import upwards, except its own parent
import { BoxContainer } from '../../BoxContainer.tsx';
// Can import siblings
import { CopyrightIcon } from './CopyrightIcon.tsx';
// WRONG: Cannot import sibling's components
// import { Menu } from './Heading/components/Menu.tsx';
// WRONG: Cannot import its parent
// import { Layout } from '../Layout.tsx';
export const Footer = () => (
<BoxContainer>
<CopyrightIcon />
<p>All rights reserved, etc.</p>
</BoxContainer>
)
Advantage #1: obvious child component relationships
The Component Trees Pattern eliminates the guesswork; the relationship between components becomes immediately apparent. For instance, Menu.tsx is neatly nested as an internal dependency of Heading.tsx.
It’s also clear that Menu.tsx isn’t utilized by anything else, which helps you to dismiss it early while scouring the code during your daily development tasks.
Advantage #2: definition of reusability is more nuanced
In the naïve approach, components were either “common” or “not common.” With reusability in mind, component trees help avoid that kind of unproductive binary thinking.
components/
Layout/
components/
Heading/
components/
- Logo.tsx
Menu.tsx
Heading.tsx
+ Logo.tsx
CopyrightIcon.tsx
Footer.tsx
Layout.tsx
BoxContainer.tsx
In the example above, if Logo.tsx becomes necessary for more components than just Menu.tsx, we can simply move it up one level. It may not be sufficiently reusable (or “common”) to be used by BoxContainer.tsx, but it’s sufficiently reusable within the context of the Layout.tsx component.
Advantage #3: minimize having to name things
Since you have component trees, there’s no need to classify directory names on top of the component names cleverly. The component names are the classifications, and when you see what internal components your component consists of, it’ll also be easier to figure out good names for your components.
Bonus: Extracting code from components into separate files without thinking of names
Now, let’s consider a situation where you want to extract some utility functions from Footer.tsx, because the file is getting a bit large, and you figure you could break out some of the logic from it rather than breaking up more UI.
While you could create a utils/ directory, that would force you to pick a name for whatever file you want to put your utility functions in.
Instead, opt for using file suffixes, like Footer.utils.tsx or Footer.test.tsx.
components/
Layout/
components/
Heading/
components/
Logo.tsx
Menu.tsx
Heading.tsx
CopyrightIcon.tsx
+ Footer.utils.tsx
Footer.tsx
Layout.tsx
BoxContainer.tsx
This way, you don’t have to think of a clever name like emailFormatters.ts or something extremely vague likehelpers.ts. Avoid the cognitive load that comes with naming — these utilities belong to Footer.tsx and can be used by Footer.tsx and its internal components (once again, importing upwards).
Counterarguments to Component Trees
“That’s a lot of components directories”
Looking at this structure for the first time, that’s most people’s knee-jerk reaction.
Yes, there are a lot of ‘components’ directories. But when I work with teams to determine project structures, I always emphasize the importance of clarity over elegance.
One of the ways I define success in a repository is how both senior and junior developers view clarity, and to that, I’ve found Component Trees always contribute to that goal.
“Ugh: import … from ./MyComponent/MyComponent.tsx?”
While import … from ./MyComponent/MyComponent.tsx may not look pretty, the clarity it brings by directly indicating where the component comes from is more important.
With regards to import strings, these are examples of what creates cognitive load for developers.
- Having import aliases like import … from ‘common/components’ is mentally taxing for developers
- Having index.ts files everywhere to be able to just write import … from ‘./MyComponent’. Finding the right file will likely take more time for developers who search by file.
Final Comparison: A Complex Scenario
Thanks to tools like ChatGPT, it’s quite easy to test patterns like this for more complex scenarios in a readable manner.
After explaining the structures, I’ve asked ChatGPT to generate the “flat” directory structure on the left column and what I called the “component trees” structure to the right.
Flat Structure | Component Trees
------------------------------------+---------------------------------------------------
pages/ | pages/
index.tsx | index.tsx
shop.tsx | shop.tsx
product/ | product/
[slug].tsx | [slug].tsx
cart.tsx | cart.tsx
checkout.tsx | checkout.tsx
about.tsx | about.tsx
contact.tsx | contact.tsx
login.tsx | login.tsx
register.tsx | register.tsx
user/ | user/
dashboard.tsx | dashboard.tsx
orders.tsx | orders.tsx
settings.tsx | settings.tsx
|
components/ | components/
layout/ | Layout/
Layout.tsx | components/
Header.tsx | Header/
Footer.tsx | components/
Sidebar.tsx | Logo.tsx
Breadcrumb.tsx | NavigationMenu.tsx
common/ | SearchBar.tsx
Button.tsx | UserIcon.tsx
Input.tsx | CartIcon.tsx
Modal.tsx | Header.tsx
Spinner.tsx | Footer/
Alert.tsx | components/
product/ | SocialMediaIcons.tsx
ProductCard.tsx | CopyrightInfo.tsx
ProductDetails.tsx | Footer.tsx
ProductImage.tsx | Layout.tsx
ProductTitle.tsx | BoxContainer.tsx
ProductPrice.tsx | Button.tsx
AddToCartButton.tsx | Input.tsx
filters/ | Modal.tsx
SearchFilter.tsx | Spinner.tsx
SortFilter.tsx | Alert.tsx
cart/ | ProductCard/
Cart.tsx | components/
CartItem.tsx | ProductImage.tsx
CartSummary.tsx | ProductTitle.tsx
checkout/ | ProductPrice.tsx
CheckoutForm.tsx | AddToCartButton.tsx
PaymentOptions.tsx | ProductCard.tsx
OrderSummary.tsx | ProductDetails/
user/ | components/
UserProfile.tsx | ProductSpecifications.tsx
UserOrders.tsx | ProductReviews.tsx
LoginBox.tsx | ProductReviewForm.tsx
RegisterBox.tsx | ProductDetails.tsx
about/ | SearchFilter.tsx
AboutContent.tsx | SortFilter.tsx
contact/ | Cart/
ContactForm.tsx | components/
review/ | CartItemList.tsx
ProductReview.tsx | CartItem.tsx
ProductReviewForm.tsx | CartSummary.tsx
address/ | Cart.tsx
ShippingAddress.tsx | CheckoutForm/
BillingAddress.tsx | components/
productInfo/ | PaymentDetails.tsx
ProductSpecifications.tsx | BillingAddress.tsx
cartInfo/ | ShippingAddress.tsx
CartItemList.tsx | CheckoutForm.tsx
userDetail/ | PaymentOptions.tsx
UserSettings.tsx | OrderSummary.tsx
icons/ | UserProfile/
Logo.tsx | components/
SocialMediaIcons.tsx | UserOrders.tsx
CartIcon.tsx | UserSettings.tsx
UserIcon.tsx | UserProfile.tsx
| LoginBox.tsx
| RegisterBox.tsx
| AboutContent.tsx
| ContactForm.tsx
Now bear in mind that this is an example without any test files, utility files, or anything like that.
For a component tree structure, you can add utility or test files suffixed in the component directories.
As for the flat structure, you’d likely have to create a separate utils directory to wrap your head around what’s already quite a heavy cognitive load.
Don’t Use File-Based Routing? Even Better!
Before working with Next.js, I used to advocate this structure but with an improvement: organizing the components by views as well.
public/
some-image.jpg
components/
BoxContainer.tsx
views/
components/
Layout/
components/
[...]
Layout.tsx
about-us/
components/
EmailForm.tsx
about-us.tsx
index.tsx (<- the start page in this example)
The upside of this is that now you not only see the dependencies between components but also which components are used by the pages (or views).
Using Next.js?
You can still accomplish this with Next.js in a few different ways, with trade-offs.
Option 1. Using pageExtensions in next.config.js
public/
some-image.jpg
components/
BoxContainer.tsx
pages/
components/
Layout/
components/
[...]
Layout.tsx
about-us/
components/
EmailForm.tsx
index.page.tsx
index.page.tsx (<- the start page in this example)
How: Utilize the pageExtensions (docs link) and suffix all your page files with something like page.tsx. This lets you have files that aren’t pages in your pages/ directory.
Potential trade-off: Since Next.js won’t let you make a page like about-us/about-us.page.tsx (that would create a URL like /about-us/about-us), you’ll be forced to have a lot of index.page.tsx files that create cognitive load for developers discerning which index file is which.
Option 2. Using “page-level” components for your pages
public/
some-image.jpg
components/
Layout/
components/
[...]
Layout.tsx
AboutUsPage/
components/
EmailForm.tsx
AboutUsPage.tsx
pages/
about-us.tsx
index.tsx
How: For each page you create, you also create a component for that page in your components/ directory, which will be the single component that your page renders.
Potential Trade-off: Having pages files that are practically empty (except for the getStaticProps and other hooks) can be seen as an annoying extra step whenever you’re off to work on some page, having to immediately go to the page component.
“I Like It — But How Do I Enforce a Structure Like This?”
Many developers, including myself, use our code editor or IDE to detect and automatically import modules for us.
This may cause developers to break any of the rules we listed earlier accidentally.
This is where eslint comes into play, a truly underrated tool for creating your own enforcement of standards for your repository.
ESLint is a highly configurable open-source JavaScript linting utility that allows developers to discover and fix problems with their JavaScript code without executing it. It helps to enforce coding standards and style guidelines, improving the consistency of your code and potentially preventing bugs.
When integrated into your development process, ESLint can provide early feedback on your code, allowing you to catch errors and bad patterns directly in your text editor before they become bigger problems. The tool is fully pluggable, giving you the flexibility to adjust the coding standards based on your specific project or team requirements.
One of the great things about ESLint is its extensibility. You can customize the rules you want to apply to your codebase or even create your own. This makes ESLint not only a tool for catching potential bugs but also for enforcing a consistent code style and structure. For instance, you can set up rules that prevent importing modules in a way that contradicts your project’s directory structure, as we’re about to see.
Since it can be a little finicky to create your own ESLint plugin, it is nice to utilize an existing one. I will share two rules I created using eslint-plugin-import.
Creating lint rules to enforce component trees
- yarn install eslint-plugin-import
- Modify your .eslintrc.json file to have the following:
// .eslintrc.json
{
"plugins": [
// [existing plugins]
"eslint-plugin-import"
],
"rules": {
// [existing rules]
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["../**/components/**/*"],
"message": "Do not import from a higher level component's internal components, move that component further up the directory tree instead."
},
{
"group": ["**/components/**/components/**/*"],
"message": "Do not import internal components used by a sub-component, move that component further up the directory tree instead."
}
]
}
]
}
}
If you run into compatibility issues with your repo, you can ensure that these rules only apply to certain directories, like those below:
// .eslintrc.json
{
"plugins": [
// [existing plugins]
"eslint-plugin-import"
],
"rules": {
// [existing rules]
},
"overrides": [
{
"files": ["components/**/*.{tsx,jsx,ts,js}"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["../**/components/**/*"],
"message": "Do not import from a higher level component's internal components, move that component further up the directory tree instead."
},
{
"group": ["**/components/**/components/**/*"],
"message": "Do not import internal components used by a sub-component, move that component further up the directory tree instead."
}
]
}
]
}
}
]
}
Bottom Line
Please give this component structure a try. It’s my sincere hope that, like me, you’ll find it so intuitive and efficient that there’s no going back to other, more convoluted structures that don’t simplify component management as they should.
I’d also like to thank you for the advice I found on the internet many years ago that led me to adopt this pattern.
Thanks for reading.
A Better Frontend Component Structure: Component Trees was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.