React Project Structure

There's not a lot of guidance out there about how to structure React projects. The guidance that is out there seems fine at first until you realize it doesn't scale much at all past the simple examples used to explain it.

Introduction

I've been working heavily in React at work recently, and coming from Ruby on Rails, I found the lack of direction in how to organize my React files really frustrating. I never felt confident in where I put things, and I often had a hard time finding the things I needed. What follows is my guidance on how React projects should be structured and why I think the way that I do.

Bad React project structures kill productivity

Having worked in several different React code bases, I've come to realize that this lack of structure is killing productivity. With an uncertainty in file placement comes an unwillingness to create files. When developers are unwilling to create files, the result is, inevitably, huge several-hundred-line files that are impenetrable to even the most experienced developer without extensive refactoring.

Finding opportunities to improve

I've recently had the opportunity to start a new Gatsby project at work. With the responsibility of starting a new code base comes the power to set precedent for how code should be structured in that code base. Because of this, I've been spending a lot of time thinking about React project structures in Gatsby.

Flat file structures

Supposed benefits of flat file structures

The official React docs say avoid too much nesting. The main benefits to this approach seem to be that flat structures force you to create descriptive names and the names that are created are easy to find with a global search.

I think the benefits of flat file structures as listed above are only partially true. Long names are not always descriptive. Sometimes they're just long. And unless you have strong systems in place for enforcing naming conventions that create descriptive/helpful names, it's pretty easy for names to get out of sync with the things they're describing.

For example take the file name AdsManagerCustomAudienceSelectorTypeaheadToken.js. This name seems to imply that this file creates a typeahead token for the ad manager's custom audience selector. What do you do when you realize this typeahead token could also be used for the standard audience selector? Do you keep the name the same and use it in both places? If so, it's a descriptive name, but it's wrong 50% of the time. Do you rename it to just AdsManagerAudienceSelectorTypeaheadToken.js? If so, why wasn't it named that before? Is there already a typeahead token being created for the standard audience selector? If so, does the file that makes the typeahead token for the standard audience selector already exist? If so, what is it called? AdsManagerAudienceSelectorTypeaheadToken.js or AdsManagerStandardAudienceSelectorTypeaheadToken.js or maybe even AdsManagerSelectorTypeaheadToken.js? To answer this question, you'll need to find the other selectors and then look at their list of imports to figure out where they get their typeahead tokens from. That sounds hard.

I think flat file structures are great if you know what you're looking for. If not, good luck! Discovery of existing code and how it relates to the rest of the application is very poor with flat file structures.

Supposed drawbacks of nested file structures

The official React docs say that when using deeply nested file structures, "it becomes harder to write relative imports between them, or to update those imports when the files are moved." This is true in JavaScript (JS), but not in TypeScript (TS). Since I use VS Code and VS Code is tightly integrated with the TS compiler, importing named imports from another file is typically as easy as hovering over the name of the function i want to import, clicking "Quick Fix", and then clicking import 'foo' from module "bar":

Similarly, if I move a file, VS Code helpfully asks if I want to update the imports in that file and the imports pointing to that file. Thanks to TS, imports are no longer a huge pain point when writing frontend code.

Flat file structure conclusion

I don't think flat file structures make sense any more. The arguments for using them depend on the ability of engineers to choose good names so the content of each file is evident. But how can we depend on engineers to choose good names when it's universally accepted that naming is a hard thing to do?

The main argument against nested file structures is that writing relative imports is difficult, and this just isn't true anymore. The tooling for JS, and TS in particular, has improved to the point that writing imports is a solved problem if you use the right IDE.

Clearly, nested file structures are the way forward. Every other programming language I've used has been heavily in favor of using folders for organization, why shouldn't I continue to organize my code with folders when writing React?

Nested file structures

I looked around the net and read a few more articles about structuring React projects, and most of them were pretty disappointing. I mean, any article that suggests I create a top-level components/ folder for a React project is kidding, right? It's a React project, half of the files are components.

Putting everything in a top-level components/ folder is just adding an extra layer to each file's path that gives the reader zero useful information. When I see a top-level components/ folder, I instantly think "where are these components being imported?" The answer to this question is invariably different from project to project.

For me, top-level components don't make sense. If they're shared components, then why don't we put them in a shared/ folder? If they're page- or template-level components, why don't we put them in a pages/ or templates/ folder? I know these are components, because this is a React app! Why should I care about these top-level components?

I think every name should provide some meaning to the reader, and the folder names we choose are no exception.

Rolling my own project structure

Since I couldn't find a good article that outlined a project structure I could point my team to as a reference, I've decided to write my own. The goals of the file structure I'm recommending are (1) make it easy to see how files are related to other files, (2) make dependencies explicit and uni-directional, and (3) provide useful information to the reader as quickly as possible.

Since I'm working in a Gatsby + TypeScript project right now, this article will focus on using those two technologies. Of course, the rules I outline below should be pretty easily ported to other React projects that make use of Create React App or whatever other framework you choose.

An example project structure

I'll show you an example file structure first. Examining it and referring back to it when reading the rules below will help you to understand how it works.

shared/
  components/
    Layout.tsx
    Layout/
      Header.tsx
      Footer.tsx
  hooks/
    use-window-inner-width.ts
  modules/
    post-chat-message.ts
  types/
    product.ts
templates/
  ProductTemplate.tsx
  ProductTemplate/
    Details.tsx
    Details/
      select-product-details.ts
    SimilarProducts.tsx
    SimilarProducts/
      happy-customer.jpg

The rules

I've separated out the rules into several different categories. Each rule is a rule, not a law, so you may break them if you have a good reason to do so, but you probably shouldn't. Even if you have a good reason to break a rule, it's often worth it in the long run to just follow the rule anyway just to make sure there are as many examples of how to follow the rules as possible for future developers to follow.

Although I'm mainly focusing on file placement in this article, I've listed the file placement rules last because they make the most sense in a project that follows the naming and file content rules that I've listed first.

File content rules

  1. Files have a single export.
  2. All exports are named exports.
  3. Imports only come from files that are deeper in the file structure or the top-level shared/ folder.
    • e.g. import { x } from "../some-module.ts"; is not allowed because it uses ../.
    • e.g. import { Layout } from "../../../shared/components/Layout.tsx"; is allowed because it's importing from the top-level shared/ folder.
  4. All files are shorter than 100 lines long.

Naming rules

  1. Files that export a React component are named identically to their export.
    • e.g. ProductTemplate is exported from ProductTemplate.tsx
  2. Files that export anything other than a React component are named after their export using kebab case.
    • e.g. useWindowInnerWidth() is exported from the file use-window-inner-width.ts.
    • Files that break rule FC1 (files have a single export) should be named after the concept that the file as a whole represents (e.g. cookie-accessor.ts exports functions for reading and writing cookies.)
  3. No files are named index.*.

File placement rules

  1. Files are placed in a folder adjacent to and named after the file in which they are used.
    • e.g. <SimilarProducts> is used in <ProductTemplate>, and these components are located at templates/ProductTemplate.tsx and templates/ProductTemplate/SimilarProducts.tsx, respectively.
  2. Test files are adjacent to the file they test.
    • e.g. post-chat-message.test.ts is in the same folder as post-chat-message.ts.
  3. Files with exports that are expected to work the same everywhere they're used in the app AND are imported into three or more other files are placed in a shared folder at the project root. (Within the shared/ folder, all of the other rules continue to apply. It is not a junk drawer. It is a location for files that are broadly used across the app that serve a single, specific purpose.)
    • e.g. shared/components/ButtonLink.tsx
    • e.g. shared/hooks/use-window-inner-width.ts
    • e.g. shared/modules/post-chat-message.ts
  4. Files that are imported into two other files are placed beneath the importing file that is lowest in the file structure, duplicated, or shared.
    • e.g. useProductAvailability() is imported by ProductTemplate.tsx and ProductTemplate/SimilarProducts.tsx, so it is placed at ProductTemplate/SimilarProducts/use-product-availability.ts.
    • If the importing files do not live directly above/below each other, then the imported file can be duplicated or shared:
      • Duplicating the file means to copy the file as-is beneath each of the files that imports it. Duplication is useful when two components use the shared file and it's likely that they will need to customize the shared file in the future.
      • Sharing the file means to move it into the root shared/ folder. Sharing is useful when a file contains logic that must be applied universally across the app.

An explanation of the rules

Now, as always, I think people understand things best when they know the reasons why. And people follow rules most often when the benefits of following those rules are made obvious. So here's an explanation of why you should follow each of these rules and the benefits you'll reap from doing so:

File content rules

  1. Files have a single export.
    • While this rule is listed first, it's mostly a side-effect of following rules FC4 (files are shorter than 100 lines) and FN1 (files are named for their export). If your files are smaller than 100 lines, then you might not be able to fit another export in your file. If files are typically named for their export, it may just be easier to split the file and mindlessly follow the naming rule rather than figure out a name that makes sense as a whole for all the exports.
  2. All exports are named exports.
    • Default exports require the importer to give the imported object a name that may or may not match the exported name. Named exports make the importer use the same name that the item was originally given.
    • By following this rule, if you see a file imports Foo, then you can bet that import will come from a file named Foo. This makes it very easy to jump from one file to the next because you have a high degree of confidence in the name of the file you want to jump to
    • Named exports also make it easier for VS Code and other IDEs to correctly suggest the correct imports to add to your code because the name you're using will match exactly the name that is being exported.
  3. Imports only come from files that are deeper in the file structure (except for top-level shared/ files).
    • This rule is probably the most important rule. By following this rule, your React project structure will match your React component tree. Are you on the login page looking for the <SocialSignInButton>? You can bet it's going to be in pages/Login/.../SocialSignInButton.
    • A huge benefit of this structure is that it gives you a really good idea of how big the React subtree for a particular component is. Sometimes you just really need to refactor something in order to really "get" what's going on. If you're unsure of the size of that refactor because the React subtree is more than a couple levels deep, then you might never get around to doing it. However, if you follow these rules and see that there are only four components in the directory structure under the component in question, then you instantly know you've only got to read (and refactor) fewer than 400 lines to figure out what's going. By clicking around that structure, you can more easily get a grasp for how much needs refactoring and make a more informed decision about what parts to refactor first.
    • This structure quickly lets you see where a component or resource is being used. Got an images folder full of stuff no one wants to take the time to go through and clean out? Then this rule is for you. With this rule in place, images are always placed under the component that's importing them. If a component is deleted or no-longer used, then it's super simple to also delete the entire folder with the same name to get rid of all the components and resources that were used only by that component. Code cleanup doesn't get much easier than that!
    • By forcing imports to always come from a child folder, you can be pretty sure that you will not have any circular dependencies in your project.
      • Of course, you could end up with a pair of shared/ components depending on each other, so you still need to be careful about that. However, this is less likely to be a problem. If you're following FC4 (files are shorter than 100 lines), then it's not likely that you'll have big important modules that need to be imported everywhere that accidentally end up importing from a file that imports from them. Small files are likely to have fewer dependencies, and those dependencies will often be unique to that file.
  4. All files are shorter than 100 lines long.
    • This rule is just a good way to get people to follow the Single Responsibility Principle (SRP). Almost every time I run into code that makes no sense to me, it's because it's not following SRP.
    • When a file is short, it's likely to follow the SRP, and when something follows the SRP, it's easy to name. This makes it easy for people to split things out into new files and encourages refactoring. Refactoring into smaller files results in more concise and more readable code.
    • If a file is both small and exports things that are used in many different places, then congratulations, you've made a reusable chunk of code! You can reward yourself by promoting your chunk of code to the top level of the shared/ directory. While this will result in many files being added to the top-level shared/ folder, the great thing is that these files will be very compact, tightly-focused bundles of reusable code. The shared/ folder will actually be a useful place for developers to look when trying to figure out if they really need to write that next chunk of code or if someone else has already written it for them.

Naming rules

  1. Files that export a React component are named identically to their export.
    • This makes it obvious that a file contains a React component and which component it is.
  2. Files that export anything other than a React component are named after their export using kebab case.
    • About half of the files in a React project are component files. The rest are modules. By naming modules with a completely different casing system, readers can see at a glance if the file they're dealing with is a module that's likely to contain logic or a presentational component.
    • Logic in modules is often more complex, so it should be tested. A file that is named with kebab-case that is checked in without an accompanying test file should be a red flag. While React components should also be tested, it's typically less vital to do so if those components are written to be merely presentational. By making files that must have tests look very different from files that might not need tests, we can help to nudge future developers to add tests where they're needed.
    • Files that export things that are not React components are more likely to break rule FC1 (files have one export). Some files export a list of constants or a couple closely related functions that read and write from the same data source. By naming files with kebab-case, these multi-export modules can be more easily named: the kebab case makes it impossible to name the file identically to any export, so the author is hopefully more willing to think for a second and come up with a name that matches what the file does as a whole rather than mindlessly naming the file after each of its exports. (e.g. cookie-accessor.ts might export readCookie() and writeCookie().)
  3. No files are named index.*.
    • I wish this rule didn't need any explanation. If you're using a modern editor, you're probably used to using the "jump-to-file" command. Naming files index.* ensures that this feature quickly becomes useless because of the hundreds of identically-name files. Yes, you can get around this by jumping to the index file in the correctly named folder, but that's just extra work.
    • Files named index.* also make import lines that are harder to read. The line import { Foo } from "./Foo"; Could mean that we're importing from ./Foo.ts or ./Foo/index.ts. It's impossible to know for sure without looking at the file system if you allow index.* files.

File placement rules

  1. Files are placed in a folder adjacent to and named after the file in which they are used.
    • By placing files under the files that need them, the relationship between that file and the rest of the app is obvious.
    • If a file is deleted because it's no longer used, you can also delete the folder with the same name to delete all the files that only the deleted file depended on. This makes cleanup of dead code very easy.
  2. Test files are adjacent to the file they test.
    • Tests are super important. By putting test files adjacent to the files they're testing, you can quickly see which files are tested and which files are not. Your file system should alternate between test files and the files that are being tested. Two non-test files next to each other is a great reminder to the developer that they should write tests!
  3. Files with exports that are expected to work the same everywhere AND are imported into three or more other files are placed in a shared folder at the project root.
    • By waiting until a file has been imported three times, we can be confident that the functionality exported by that file is broadly applicable. That makes it a great candidate for future reuse.
    • The shared folder should only contain code that will not need to be customized in the future. The code in the shared folder should solve a single focused problem that needs to be solved in many places in the app.
    • By making sure that shared code only solves focused problems, we can be sure that updates to the shared code really should be applied to all places where that code is shared. If the shared code is so general that it needs to be customized for each place it's being used, then it should probably be duplicated and differentiated in each of the places it's being used.
  4. Files that are imported into two other files are placed beneath the importing file that is lowest in the file structure, duplicated, or shared.
    • This rule really follows as a consequence of FC3 (imports only come from files that are deeper in the file structure). If you have a file that needs to be imported into two components in the same React tree, the only place you can put it that would make sense is under the more deeply nested component.
    • If the two files that import the shared file are not in the same tree, then you have to make a choice between duplicating the code and placing it under both importing files or promoting it to the shared/ folder. Your first instinct should be to duplicate the code. The shared/ folder should be reserved for code that functions identically across the app. If you're tempted to add input parameters to your code to customize its behavior for different parts of your app, then it's likely that you're breaking SRP. In that case, Your code should be written as two different functions, and those functions should live under the two files that need slightly different things.
    • On the other hand, if your code is doing something sufficiently general or sufficiently specific that it is extremely unlikely to need to be customized in other parts of the app, then feel free to promote it to the shared folder even before it's used three times.
    • You should think of the shared folder as containing perfect, reusable code. Once code is added to the shared folder, it probably shouldn't ever need to change except to fix bugs or to change behavior that needs to be changed everywhere.

Well, that's it. That's how I think React projects should be organized. I hope this helps you organize your React code better so you can be a more productive software engineer.

Photo by Alain Pham