Ben Williams

Ben Williams

biwills.com

Better Dependency Trees in React Apps

Published 2018-07-03

2022 Update: In 2018, this was useful for many of the JavaScript applications I was writing. Now that I write almost only TypeScript, I view it as bikeshedding

When looking at most React app, there is usually a components folder. We are going to focus on that folder. While I understand that folder structure, like linting, can be viewed as bikeshedding, I believe there are a few benefits to setting a specific and consistent folder structure. In general, a consistent folder structure increases readability and removes the cognitive burden of trying to understand the layout that each folder may have. In the context of the folder structure detailed later in this doc, its primary goal is to design a system in which dependency trees are easy to understand for each component.

As a component folder grows, goals change, A/B tests are written, and design flows change. Numerous components will be added, removed, and changed. To prevent large refactors, it is imperative to structure code in a way that empowers us not only to add to the codebase but also to modify them without unintended effects and more importantly delete them. The only way to prevent unintended effects or delete a component (let's call it CompA) is to know all component that requires ComA as a dependency, therefore there a significant benefit in creating a system that helps us understand the dependencies of each component. While ES modules imports (import ExampleComp from './ExampleComp') have made figuring out the dependencies of a file significantly more straightforward than the global world that was common in Angular 1, it still doesn't give a complete picture of the whole dependency tree.

Guidelines

When detailing all of these guidelines, it is essential to know that these guidelines are designed to work infinitely deep on a file hierarchy. By keeping the same rules infinitely deep, we can allow individual developers freedom to decide on the levels of abstraction and how to break up their components into sub-components that make sense for a specific component, without losing the consistent APIs.

It is also important to know that these are guidelines, not hard rules. In 99% of situations, I have found these guidelines to be a great way to organize components. However, sometimes components don't have perfect hierarchies, and these rules may not be the best solution. The guidelines are broken down into three primitive types.

Container Folders

A container folder is the first of our top-level primitive of our design system. A container folder holds named components but does not have its own index.js file or export a default component or named exports. All projects that adopt the folder system laid out in this document have 1 or more container folders. The most common example of a container folder is the top-level components folder. A container folder may hold container folders, component folders, or component files. There are almost no rules in respect to how a container folder should be structured, other than it should not have an index.js file. All container folders should follow camelCase naming. If we want our folder to export multiple named exports, we should use a Component Folder. Any file that can import a container folder can import infinitely deep inside the container folder as long as the guidelines are reevaluated at each step down the tree. Here is an example container folder with valid imports marked with ✅ and invalid imports marked with ❌ (notice how all our guidelines hold no matter how deep down the file tree we go):

components/ ## root container folder
  Button/ ## component folder ✅
  OurView/ ## component folder ✅
  keyboardViews/ ## sub-container folder  ✅
    __tests__
      BlueKeys.test.js
    YellowKeys/ ## component folder ✅
      index.js ✅
      SpecialKeys.js  ## ❌ more detail below why this isn't a valid import
    BlueKeys.js ## component file ✅

Component Files

A component file is one of the primary ways we export components. All component files should be PascalCase & either be the name of the default export of the single component the file exports or a descriptive name when exporting multiple named exports. A component file should never have both a default export and named export(s). All components exported from this file are available to other modules; changing the exports should be considered a breaking API change. If you need to export internal functions or sub-components of a component for testing reasons, you should use a component folder. Tests for a component file are in a __tests__ directory of the parent folder as a sibling of the component file, and follow the [filename.test.js] naming convention. A component file can import from sibling container folders, component files, and component folders with import rules being evaluated at each step down the tree. If necessary, It may also import from parent container folders. This exception to the import rule should only be used in situations where primitive building objects (Button, Text, etc.) are kept at a higher level. For example, you might want to use a <Button> component in a keyboardView. You should, however, strive to keep components at the lowest level of a component tree. It is much easier to bring components up a dependency tree at a later time than to move them down a dependency tree.

components/
  Button/ ## <Button /> is exported
  OurView/
  keyboardViews/ ## nothing is exported
    __tests__
      BlueKeys.test.js
    YellowKeys/ ## it is okay to use <Button> here
      __tests__
        index.test.js
      index.js
      SpecialKeys.js
    BlueKeys.js

Component Folders

A component file is the third & most used primitive that we have in our guidelines. Component folders and component files share many of the same rules and are designed to be drop-in replacements for each other. Like component files, they should only export a default export or named exports, never both. The folder name should always be in PaselCase, just like component files. The PaselCase folder name signals that the API stops at the top level. In other words, you should never import past the top level of a Component Folder. In this way we can design the API interface to a component as a file or folder, then refactor it without worrying about the internals of the component. The goal of a component folder is to limit the scope and namespace of components as much as possible. All internal components should still follow the same rules infinitely deep. For example:

components/
  Button/ ## <Button /> is exported and can be used in <View1 /> & <View2 />
  View2/ ## <View2 /> is exported
  View1/ ## <View1 /> is exported
    __tests__
      index.test.js
    index.js
    SubView.js ## This should never be used in <View2 /> or any sibling or parent of View1

Summary

To summarize what we have gone over, we have three primary types: container folders, component folders, and component files.

Container Folders

A container folder should be camelCase. It should never export anything itself, only hold component folders, component files, or another container folder:

Good:

components/
  buttons/
    BlueButton/
    RedButton.js
  View2/
import BlueButton from "components/buttons/BlueButton";
import View2 from "components/View2";

Bad:

components/
  buttons/
    GreenButton/
    RedButton.js
    index.js
  ViewXX/
  index.js
import { GreenButton } from "components/buttons";
import { ViewXX } from "components";

Component Files

A component file should be PascalCase, (preferably) only have one default export or two or more named exports, never both, and never export anything not designed for external use (i.e., for testing).

Good:

components/
  View22.js
  buttons/
    __tests__
      RedButton.test.js # only imports <RedButton />
    RedButton.js
    GreenButton.js
import RedButton from 'components/buttons/RedButton'
import { View, 22 } from 'components/View22' // this isn't ideal, default export is preferd

Bad:

components/
  View22.js
  buttons/
    redButton.test.js # imports <RedButton /> & redHelperFunc
    redButton.js
import { View22 } from "components/View22";
import redButton from "components/buttons/redButton"; // see how we can't tell if this is a component or a folder holder components

Component Folder

To files that import them, a component folder and component file are indistinguishable; this makes it very easy to refactor and break a component file into many sub-files & sub-folders as it grows. Like the component file, it should be PascalCase and only export 1 default export or 2 or more named exports, never both & never export anything not designed for use (i.e., testing). Files outside of the component folder should never import the internals of the component folder.

Good:

components/
  buttons/
    GreenImageButton/
      __tests__
        index.test.js
        ImageHolder.test.js
      index.js # exports only <GreenButton />
      ImageHolder.js
      BaseButton/
        __tests__
          index.test.js
        index.js # exports only <BaseButton />
        SubButton.js
import GreenImageButton from "components/buttons/GreenImageButton";

Bad:

components/
  buttons/
    GreenImageButton/
      __tests__
        index.test.js
        ImageHolder.test.js
      index.js # exports only <GreenButton />
      ImageHolder.js
      BaseButton/
        __tests__
          index.test.js
        index.js
        SubButton.js
import ImageHolder from "components/buttons/GreenImageButton/ImageHolder";