📂 Nominal & Structural React Architecture – Part II
Predictable and scalable solution to structuring a React application via mimicking the DOM itself.
In the previous part we've touched a bit on a React project structuring ecosystem. There is no silver bullet, and even no standard, that would be appreciated and followed by the developers. Tools like create-react-app
or react-boilerplate
provide us with somewhat decent solutions, but at practice they tend to not scale that well with time.
Table of Contents
👐 Scaling
There are a couple of problems that I've noticed having arisen when the project becomes somewhat mature:
- You start getting lost in it, not knowing where to look for the module you've been working on a couple of months ago.
- Creating new modules becomes a resource intensive task – you either place them where you think they should be placed, or try to recall/recollect the approach the project has been following from the start (if there was one in the first place).
- New people are overwhelmed by what freedom the structure's simplicity provides (if it's simple like in CRA), or the complexity obtained by the adopted system (like atomic design).
Looking at the problems above, we can define the characteristics of the system that should solve them:
Predictability
The file structure should be predictable enough for the people to not think too much about where to place/look for things. It is not about how simple or flat it should be, as "simple" and "flat" comes with more trade-offs than the benefits it seemingly provides.
Scalability
Adding new things, or even moving around existing ones should not be the problem when we have a system that scales.
Strictness
It should enforce the consistent style anywhere in the project tree, however simple or complex this place in the project is (or will be).
Transparency
We are here for getting shit done, not for moving files and folders around. This system should transparently supplement what really is important – the application's code, and help the ones who are important – the people producing it.
👹 The Big Bad
Now, as we've defined the problems and what potential solutions they have, let us talk about the real game here.
Onwards, we'll build up the structure from the roots to the leaves, thus the starting point is the /
directory.
🌳 Root
The root of our project is simple enough, so, without further ado, let's quickly take a look at it:
app:
- bin
- e2e
- public
- scripts
- src
- tests
bin
stands for "binary". Conventionally, that's where.sh
scripts live.public
hosts the static files our application serves under the/
path.scripts
is for Node.js scripts and jobs.src
for all the source code.tests
,e2e
– specifically for tests, as we'd consider tests not to be a part of the source code.
⚙️ Configuration
Along with the folders above what you'll typically see in the project's root are configuration files.
They should follow the same convention we've discussed previously – .config
suffix. To name a few: webpack.config.js
, vite.config.js
, prettier.config.js
, stylelint.config.js
, jest.config.js
.
Weirdly enough, there a [at least] two exceptions to this rule: tsconfig.json
(or jsconfig.json
) and .eslintrc.js
. TypeScript configuration file has only one acceptable format and name, so we can't argue with that. ESLint, on the other hand, has multiple choices, none of which includes eslint.config.js
. I went ahead and created such a file in this blog's source code only to see it have a name unrecognizable by the program. Can be fixed easily enough by providing the --config eslint.config.js
option to the CLI, but it may seem pretty stupid to do so just for the sake of having a consistent name for ESLint (although I still prefer consistency 🙃).
🔨 Build tools
It is pretty common to create separate configs for different environments. Assuming that every project/team/company can have their own environments, I'd just propose an overall scheme for naming configs for build tools: [name][,.env].[extension]
. E.g.: tsconfig.json
(main config), tsconfig.ci.json
, tsconfig.build.json
, etc. Notice the absence of a suffix for the "main" config, as it often would define the shared configuration which all environments will inherit from.
📥 Entry point
The entry point to our program, a place where it all starts, should be called index.{ts,js,tsx,jsx}
, following the Node.js convention of the main file of the module.
Its main purpose is to render our App
to the DOM and connect it to various providers. Also a good place for importing styles (if you're using CSS) or different miscellaneous scripts.
It is important to remember that in case we want to test our App
separately it should purely render the components tree, that's why we're providing it with external dependencies inside the entry point.
Unfortunately, this applies only if we are in full control of the build system. Otherwise, like in the case of Next.js, we have to stick to what it imposes on us.
🗃 Features and Nesting
Now we're talking real shit about what makes this system shine.
Feature – is basically a part of something bigger which makes this "bigger" complete. Any big feature can contain smaller features, which in turn contain even smaller features.
For example, our application can have features like a Redux store, a router, some pages, independent and shared modules. Some complex components can have smaller ones (its features) as direct children.
Every feature is contained inside a folder and has an entry point – index
file which we consider to be the public API of our feature. Everything exported (exposed) from here is intended to be used by the outside consumers of the feature.
Talking about the "nesting" thing, I suggest looking at the DOM tree:
We can clearly see that every DOM node has other DOM nodes inside of it. This is one of the first concepts we learn as front-end engineers and the one to be with us forever.
So what's the deal with DOM? When building a React application, what we do fundamentally is just construct a DOM tree out of components. That's what React is for – simplify DOM manipulations. Now, what if our file structure resembled the very DOM tree we're building? What if the child component of our page goes directly inside this page's folder, and not into some distant place called src/components
? And what if this component renders some smaller components, which go directly inside its folder and not into the src/components
on the same level with it?
Nesting lets us reflect the DOM tree in our file structure, defining component-centered features as first-class citizens. This way we could almost guarantee that the component we see on the UI maintains its hierarchical location in the file tree:
post:
progress:
hooks:
- index.ts
- use-progress.ts
- index.ts
- progress.component.ts
share:
- index.ts
- share.component.tsx
- share.styles.ts
- index.ts
- post.component.tsx
- post.props.d.ts
- post.styles.ts
The word "almost" in previous paragraph stands for components which we encounter several times in different places, so that they cannot be placed under a specific feature. This is where shared
comes into play.
"DOM" word count per paragraph: 0,(8).
♻️ Shared
shared
is a place to define things that we share inside the scope it belongs to.
For example, imagine having a form
feature that renders some complex fields:
form:
shared:
components:
- input
- label
- number-input
- radio-group
- select
- text-input
What happens here is that input
is shared between text-input
and number-input
, while label
is also shared between all inputs at once.
We specifically place them inside the components
folder to provide the scalability for shared
, which may host not only the components but something like hooks
, utils
, entity
and so on.
In practice, we'd not have that many nested shared
modules across the application, but it is 💯% to have the src/shared
directory.
Also, the components
folder here allows for handy grouping of components, which outside of DOM context can be collected safely this way.
A word on entity
Not sure if you've encountered this weird thing before, but it is actually quite simple – anything you could previously define as a "constant", "interface", "type", etc is eventually an "entity". It's a single real/abstract object/scalar value that defines something meaningful in your domain model or architecture. For example, an interface describing a DTO can be considered an entity.
🧬 Core
core
is where we put our "core" features. Usually, they are just high-level standalone modules without which our app is not what it is. These may include app
, store
, theme
, layout
, and some other arbitrary features you may need in your project.
📖 Pages
In React we are most likely to build websites. And websites have pages. Simple enough.
pages
directory is meant to organise our actual pages and separate them from anything else in the file structure (which we have plenty already).
Each page is a top-level route (goes from /
, that is) that can have nested pages inside, just like anything else. Here it would be a good practice to also follow the file system routing names from Next.js – path parameter of a page's URL is enclosed in square brackets:
pages:
posts: # /posts page
- [id] # /posts/:id page
🃏 Modules
A module is a self-contained feature that does not necessarily have something above it to make it work. Think of it as a "container" in terms of container/presentation components separation, or a "smart" component.
A single module can have many instances around the application, and anywhere you place it, it behaves the same and just works out of the box.
For example, if we need to include different layouts on different pages, a Layout
feature can be considered a module.
🎰 Tests
As stated previously, tests are actually not a part of our source code. This means that they belong outside of the src
folder.
For structuring test files I'd simplify the system a bit and group them just by features. This way we'd not have to completely mirror the structure of the application code where it's not necessary, as usually we do not want to test every single line of code however simply it behaves (but I'd leave this statement for another topic).
🖋 P.S.
Still here? Good. Take your time, think about what you’ve read so far, try to properly chew and swallow this material.
Some of the concepts, if not all mentioned here may be new to you.
But, actually, there are no innovations and custom things involved. Everything mentioned here was constructed, well thought-out and tested by many other people before. I've been digging this system for about two years now with many applications written and many people giving their positive feedback on it.
I believe that following defined conventions, intuitive approaches, and being as clear as possible in the code and its structure, would slow down the entropy of the constantly expanding codebase and make our lives and the very perception of this codebase much easier. No second thoughts, close to none excess abstractions and unneeded complexities.
It may still have enough flaws and room for improvement, which I find to be a positive sign of something that constantly evolves and tries to be even better than before.
🤓 TL;DR
app:
- bin # .sh scripts
- e2e # end-to-end tests
- tests # unit/integration tests
- public # static assets
- scripts # Node.js scripts
- src:
- core: # top-level "core" modules
- app
- theme
- store
- modules: # standalone self-contained modules
- layout:
# nesting child features reflecting the DOM structure being rendered
- header # "component" feature is a first-class citizen
- footer
- index.ts # feature's entry point
- layout.component.tsx
- layout.styles.ts
- pages: # literally pages, routes
- posts:
- [id] # path parameter
- shared: # anything shared between different modules
- components: # grouping components here as they're unrelated to the DOM
- button
- index.ts
- hooks
- utils
- index.ts # application's entry point