Morley Zhi in frontend, javascript

How OkCupid organizes its multi-page React app

While writing this blog post, I noticed that I haven’t seen many articles about how other large apps organize their files. Maybe the problem is obvious, unimportant, or mundane, but those are the types of problems we least want to reinvent a solution to. After tabs and spaces, directory structure must be the next-biggest area for bikeshedding. (And what is a bike shed but a directory structure for your bike?)

So here's the bike shed we've built. We think we’ve found a file structure that scales for any size project, is easy to reason about, and makes reusing code straightforward.

Before we go into that, we have to explain a little how Javascript works on our site.

How Javascript works on OkCupid

OkCupid’s desktop and mobile sites are multi-page apps: the HTML of each page you load is generated server-side, and when you load a new page, you ask our servers to generate the next page.

No, you did not accidentally stumble onto an article from 1995. Rewriting the entire site as a single-page app would commit us for at least a year, and we'd have to fight missed edge cases and new bugs along the way. Instead, we’ve elected to upgrade the site feature-by-feature, which React is particularly adept at. (Incidentally, this gives us room to adapt when there’s a sea change in libraries, like when Reflux support dried up in favor of Redux.)

Anyway, this is how our multi-page app (or, as we call it, website) works. When you load a page on OkCupid, you'll download three piece of Javascript:

<script src="//includes.okccdn.com/flat/synced/desktop/js/vendor.min.js?v=1a7a5f9563d3f27"></script>  
<script src="//includes.okccdn.com/flat/synced/desktop/js/common.min.js?v=e31eca8c4a82ec6"></script>  
<script src="//includes.okccdn.com/flat/synced/desktop/js/home.min.js?v=72b30a0fad24251" async></script>  

vendor.min.js includes libraries that will very rarely change -- React,jQuery, Moment, etc. common.min.js contains features that appear on every page, but could get changed frequently under active development. home.min.jscontains features that only appear on the homepage. The match search page loads matchsearch.min.js, signup loads signup.min.js... you know how naming works.

We generate vendor, common, and the individual page entry points with Webpack. The source files are in their own Git repository, okfrontend, which has this rough file structure:

├── src
│   ├── apps
│   ├── ducks
│   ├── pages
│   ├── bundles
│   └── shared
│       ├── util
│       └── components
│
└── tests

Since "ducks" has to be the oddest word in that list, let's start there.

Part 1: Ducks

When Facebook debuted the Flux pattern, we at OkCupid could immediately feel the large weight lifting off our shoulders. It wasn’t until I actually implemented it that I realized how many files we'd be editing to keep that weight off. You need to find a home for action names, action creators, stores/reducers, and more. Simple changes require changing several files. It was madness.

The ducks pattern does wonders for helping us not get RSI from opening and closing files all day. Action names, action creators, and reducers for a given feature will normally be changed together, so it makes sense to organize them all in the same file.

For the most part, one ducks file corresponds with one feature on the page. At OkCupid, we call those features "apps."

Part 2: Apps

OkCupid's web team is fewer than five people, and like any good small team, we are lazy as hell. We want to reuse stuff as much as possible. That’s why we chose React in the first place, right?

For simple components like <Str> (a component for marking strings as translatable) re-use is simple: just include the component. We store those types of commonly-reused components in src/shared/components.

But consider a PhotoUploader component. It needs UI for browsing, uploading, and cropping photos; it keeps track of all that imputed data; and it sometimes reports to other parts of OkCupid when an upload is successful, failed, or canceled.

Including this as one React component is going to get messy real fast. That’s why we chose Redux in the first place, right?

So when a feature is going to be large and complex enough, we instead place it in src/apps:

src/apps/photo_upload/App.jsx  
src/apps/photo_upload/components/UploadButton.jsx  
src/apps/photo_upload/components/Thumbnail.jsx  
src/apps/photo_upload/components/Status.jsx  
src/apps/photo_upload/util/uploadPhotoFile.js  

We try to keep apps as focused as possible, to promote both reuse and developer sanity. Another app that wants to make use of photo uploading would simply need to import src/apps/photo_upload/App.

“App” is unfortunately the most overloaded buzzword in the modern software development lexicon, but we've defined it as specifically we could. Plus, it's fewer letters than "feature." Told you we were lazy.

If a React component is a brick, then the app is the wall. But we still need a way to combine those walls into a bike shed.

Part 3: Pages

Each page on OkCupid should only load one page-specific bundle of JS, but each page could also be using several apps: our signup page, for example, imports Signup, Login, and Splash. So almost all of Webpack’s listed entry points (which spits out those pagename.min.js files) will point to an entry file in the src/pages directory:

    signup: "src/pages/signup/index.js",

This signup page then composes those apps onto the page:

import Splash from "apps/splash/App";  
import Login from "apps/login/App";  
import Signup from "apps/signup/App";

const Signup = (props) => (  
    <div className="signup">
        <Splash showSignup={props.navigateToSignup} showLogin={props.navigateToLogin} />

        {props.showSignup &&
                <Signup />
        }
        {props.showLogin &&
                <Login />
        }
    </div>
);

Ducks, apps and pages make up the skeleton of OkCupid, but in practice, we tend to not create them that frequently (at least, not every day). On the other hand, we write file paths a lot.

Also important: file paths

There are few things worse than seeing this at the top of a React component:

import Modal in "../../../shared/components/Modal";  

Good luck fixing that path if you reorganize your app (or – ugh – you leave out a ../). That's why we set the import resolution root to src/ to get straightforward absolute paths:

import ClientStats from "shared/util/ClientStats";  
import Str from "shared/components/Str";

import { STEP_TRANSITION_SPEED } from "apps/onboarding/util/constants";  

Then again, if you're deep in src/apps/my_app, you don't really want to type out all of these:

import Logo in "apps/my_app/components/header/Logo";  
import Navigation in "apps/my_app/components/header/Navigation";  
import Body in "apps/my_app/components/header/Body";  

Within an app, we use relative paths since the directory structure is simple and shallow:

import Photos from "./components/Photos";  
import Feedback from "./components/Feedback";  
import Instructions from "./components/Instructions";

import { insertDraggingPhoto } from "./util/helpers";  
import { TOTAL_PHOTO_COUNTS, ERROR_CLEAR_TIME } from "./util/constants";  

Part 4: Bundles

The vendor and common files I mentioned earlier aren't actually "pages" in our previous definition. But their contents still need to be defined, and Webpack still needs to create files for them. So we assemble them in src/bundles:

// src/bundles/vendor/index.js
import jQuery from "jquery";  
import React from "react";  
import ReactDOM from "react-dom";  
import moment from "moment";  
import _ from "underscore";

// src/bundles/common/index.js
import "apps/messages-dropdown/app";  
import "apps/okmodal/app";  
import "shared/components/BlankState";  
import "shared/components/DisplayAd";  
import "shared/components/Modal";  
import "shared/components/Str";  
import "shared/components/Strf";  

We can then use Webpack's CommonChunksPlugin to mark these included files as common, so that other imports of those files don't result in repeated code. Our users download less code, the site loads faster, and before they know it, they're on the path to getting laid.

Conclusion

Our file structure isn't perfect, but it's done a pretty good job doing a few things.

  • There's a place for everything, from the smallest component to the largest feature.
  • When you're adding functionality to the site, it's straightforward to reason about where the code should go.
  • It's easy to reuse code.
  • Import paths are as sane as we can make them.

Plus, our file structure also doesn't lock us into anything. I said before that we're a multi-page app, but there's no reason every page couldn't be included by one file that routes between different pages. In fact, several of our pages already work like this.

Hopefully, this look into the rainbow-filled world of files and directories has been useful to you. If you'd like to learn more at way too deep a level, OkCupid is hiring Senior Frontend Engineers. If not, feel free to send us ideas or feedback. Thanks for reading!

Thanks to Will O'Beirne, Michael Geraci, Tom Jacques, Mike Cirello, and Kelly Cooper for giving notes on this post.