How we manage a massive React project at Minsilo

I rarely ever talk about lines of code when referring to a project. It’s usually a poor measure of productivity and substance. But it does say a lot about the potential complexity of a project.

At Minsilo, our core React application contains the following:

  • ~42K lines of Javascript spread across 1,671 files.
  • ~10K lines of CSS spread across 579 files.
  • First commit in 2018.
  • 1,565 commits, 240 tagged releases (more commits in practice, since we squash commits sometimes have larger pull requests).

This does not account for any other code that we may have (we also have a separate partner application and authentication client) on the frontend or in our monolithic backend.

So how do we keep it all organized?

More files

After many years of trial and error, I’ve find it’s better to keep smaller files that do one thing and rely on composition, rather than trying to do too many things in one file. It turns out that more files with less going on in each of them is generally better.

Better separation of concerns

React does not enforce any specific paradigm, unlike web frontends such as Angular. It’s not even a framework, but rather a library that is focused primarily on manipulating the DOM in a consistent and useful way. This is both a blessing and a curse.

This led to trouble for us early on in our core repository, since we did a poor job at separating concerns and setting ourselves up for code reuse.

Since then, we’ve done a lot of refactoring to clean our code up. The easy problems we had to fix were:

  • Having too many components in a file – simple: create individual files for each component.
  • Having spaghetti code (e.g. code that jumps all over the place and calls other code in a disorganized manner) – not a simple problem to solve, but it can be solved by using good code refactoring tools.
  • Poorly named components, functions, and variables – naming is the hardest part about programming and there’s always a tradeoff to be had between moving fast and naming things (poorly).

For a lot of this refactoring, I ended up using the built-in tools that Webstorm has. I’ve heard that VSCode can do some of the same things now, but I haven’t checked it out for myself.

Using good folder structure

Our application is organized as such:

src
├── components
├── data
├── errors
├── hooks
├── images
├── lib
├── services
└── styles

Digging into our components directory, we have three major subdirectories:

  • BuildingBlocks – reusable frontend components, such as our drop-in comments component.
  • Core – core functionality handling both view and data concerns, such as Layout and Websockets.
  • Features – “real world” functionality that contains useful capabilities for end users.

BuildingBlocks are essentially features in of themselves, with the notable exception being that we do not expose them independently of components under Features. In other words, we use the BuildingBlocks to add functionality to Features.

Core contains an assortment of things. Here’s what’s in that directory:

src/components/Core
├── Auth
├── Context
├── Diagnostics
├── FeatureGating
├── Groups
├── Help
├── Layout
├── Offline
├── Profiles
├── Routing
├── Schedules
├── Search
├── Settings
├── Sidebars
├── SubscriptionsV2
├── Users
├── Versions
├── Websockets
└── WorkspaceSettings

Features are real-world, end-user facing components that provide our advertised feature set.

Creating new components

Most of our new components and features are created using generators. We utilize plop for our generators.

The main plop generator we utilize is called generateContextComponent, which creates a new component with the following files:

  • index.js
  • component_name.module.css

Inside of the index.js file is a basic component that looks like this:

import { useContext } from 'react'
import classes from './component_name.module.css'

const ComponentName = () => {
    return <></>
}
export default ComponentName

This isn’t too fancy, but it takes 3-seconds to run and gives us a good boilerplate component to work with. More importantly, it also reduces the tendency to be lazy and overstuff a component, violating best practices around composability.

The component_name.module.css is a CSS module (we use CSS modules primarily) that allows us to easily add styling to each component. Styles live at a component level exclusively, except for a couple of notable exceptions (which are caused by 3rd-party libraries).1

Creating component families

We use the aforementioned structure for individual components, but it doesn’t address the needs of a component family. Component families contain collections of components and provide a logical, usually business-case-specific separation.

For example, our Goals feature is one component family. Worksheets, Strategic Initiatives and Tasks are also component families.

Component families have the following structure:

.
├── Actions
├── API
├── Components
├── Contexts
├── Data
├── Errors
├── Helpers
├── Hooks
├── Providers
├── Reducers
└── State

These folders each have their own purpose:

  • Actions: contain objects that have named actions (e.g. FeatureActions.FETCH_DATA)
  • API: contains one-file-per-API call. Files are named according to their HTTP method, such as getStrategies.js and postStrategy.js.
  • Components: our actual components live here. We use the atomic design pattern to organize our components (more on that in a minute).
  • Contexts: boilerplate context files that correspond to a Provider. These are used within our components via the React.useContext hook.
  • Data: not strictly in every component family, but sometimes we have default states or other customizable data that is not dynamic but we don’t want to live in a singular component. For example, we store manifests that we consume in our factory components here.
  • Errors: error messages are stored at this level.
  • Helpers: any utility functions that are used in this component family but not elsewhere get stored here.
  • Hooks: any React hooks that are used in this component family but not elsewhere are stored here.
  • Providers: this is where 90% of our logic lives. We use providers to encapsulate all API functions, initialization behavior, and more. (read on, I’ll describe providers in a bit).
  • Reducers: this is where state transformations occur.
  • State: we initialize our the state of our React Context store using files stored here.

Components

Components are organized according to the atomic design pattern. This roughly means every component has 4-5 folders:

  • Atoms: small components that do not contain any subcomponents and that do not wrap other components. Buttons are a common example.
  • Molecules: slightly larger components that make up Organisms. These components may have subcomponents and may be higher order components/wrappers to other components.
  • Organisms: large components that represent a single logical concept.
  • Templates: reusable components that are mostly driven by configuration.
  • Pages: components that are made available by a route in React Router.

How we deal with massive features

In some cases, we break component families down further by creating another layer of hierarchy. This can be done at two levels: either in the Components folder directly or within the component family.

The decision to break it down at the Components level or component family level is decided by:

  • If there are a lot of separate providers and reducers, then we split it at the component family level.
  • If there are a lot of components that share the same couple of providers and reducers, we split it at the Components level.

State Management

We manage state in Minsilo using React Context. Although some people complain about it being inferior to other state libraries, we’ve found it to be robust, stable and easy to use.

One of our key principles for state management is to keep all business logic outside of components. And we try at all costs to avoid multi-level prop drilling. It’s incredibly hard to follow what’s going on when you prop drill and can lead to a number of nasty side effects (and it’s just generally hard to maintain).

Providers

Most of our business logic exists in a provider. Let’s look at a really basic example:

import React from 'react'
import TestReducer from '../Reducers/TestReducer'
import InitialTestState from '../State/InitialTestState'
import TestActions from '../Actions/TestActions'
import TestContext from '../Contexts/TestContext'

export default function TestProvider({children}) {
	const [state, dispatch] = React.useReducer(TestReducer, InitialTestState)

	const crud = {
		createTest: async () => {
			// todo: call API
			value.addTest(testContents)
		},
		destroyTest: async () => {
			// todo: call API
			value.removeTest(testId)
		},
	}

	const value = {
		...crud,
		title: state.title,
        description: state.description,
		addTest: value => dispatch({type: TestActions.ADD_TEST, value}),
		removeTest: value => dispatch({type: TestActions.REMOVE_TEST, value}),
        setTitle: value => dispatch({type: TestActions.SET_TITLE, value}),
        setDescription: value => dispatch({type: TestActions.SET_DESCRIPTION, value})
	}

	return <TestContext.Provider value={value}>
		{children}
	</TestContext.Provider>
}

Much of this is boilerplate, but there are some useful things we’ve learned over the past few years:

  • The value object can become really long and unwieldy. We’ve found success in creating smaller objects that a focused on a single concern. In the above example, we have a crud object that we spread into value (e.g. ...crud).

    This allows us to access the individual functions and data for a specific concern with ease, but without making it difficult to understand and modify the provider.
  • Avoid nesting. It’s much harder to modify nested state. For example, if we had stored title and description in a test object, that would make it harder for us to modify (in other words, use title and description instead of test.title and test.description)
  • Named action type constants are better than strings. It’s technically possible to use a string to discern which action occurred in a dispatch function, but it’s prone to error since our editor cannot validate the presence/correctness of that action name.
  • Separate your API calls from data manipulation. We frequently use websockets, which receive an “event” and then do something with the data provided by the websocket message.

    For example, if a user adds a card to a page, then we will transmit that change over a websocket to all other viewers of the page and update what they’re seeing in real-time.

    Keeping the function that appends an item to the list of cards, for example, separate from the function that works with the API or the websocket allows us to reuse code and reduce the potential confusion.

Conclusion

I hope this article inspires you to improve the maintainability of your React projects.

Writing maintainable and enjoyable to work with code is a continuous process. I’m sure looking back at this post in a couple of years, I will have made several changes to the way code is organized based on new learnings and changes in project scope.

Remember it’s okay to start small and refactor your code over time. If you have any questions, feel free to send me an email.

Footnotes:

  1. If you’re a fan of libraries like SemanticUI or TailwindCSS, you may be wondering if such heavy use of component-level styling leads to a lot of duplicative code. The answer is yes and no.

    Yes, because it’s easy to be lazy and create CSS for each component individually rather than thinking about reusable styling.

    No, because we can again use composition to create containers and primitive components that give us a consistent interface across implementations. We’re heavily invested in this approach and I think it’s more flexible than CSS-based styling because it also allows you to marry sensible functionality within interfaces (i.e. adding close buttons to a dialog).

,

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: