Intro

Great, another blog post about React file structure…

Back in 2019 we transitioned our frontend codebase into a monorepo (see our previous blog post on how and why we made the switch). Our website, React Native iOS and Android apps, plus several shared supporting packages all lived under the same roof, which gave us a huge opportunity for code sharing between apps.

In the beginning the main shared library was our cross-platform design system, which gave us a write-once-render-everywhere tool for generic UI components like fonts, buttons, text fields, etc. Our web & mobile designs are also intended to have nearly identical design and functionality. So for example, the following component could render on either web or mobile:

// Profile.tsx
import React from 'react';
import { gql, useQuery } from '@apollo/client';
import { Container, Font, Avatar, Row, Loading, Button } from '@knack/ui';

type ProfileProps = {
  id: string;
  sendMessage: () => void;
};

const PROFILE_QUERY = gql`
  query profile($id: ID!) {
    user(id: $id) {
      fullName
      profileImage
    }
  }
`;

export const Profile = ({ id, sendMessage }: ProfileProps) => {
  const { data, loading } = useQuery(PROFILE_QUERY, { variables: { id } });

  if (loading) return <Loading />;

  const profileImage = data?.user?.profileImage;
  const fullName = data?.user?.fullName;

  return (
    <Container>
      <Row alignItems="center">
        <Avatar src={profileImage} />
        <Font size="medium">{fullName}</Font>
      </Row>
      <Button onPress={sendMessage}>Message</Button>
    </Container>
  );
};

This was great because a single engineer could build the same feature for the website and mobile app in half the time. This was also terrible because the same engineer (aka me) could literally copy and paste the previous file from the web to the mobile project and change a few lines to run on react-native. Not even kidding, some of my pull requests were about 80% duplicate code.

Fast forward a year or so, things got a little messy. Our monorepo ballooned to a large collection of mostly identical yet duplicate files for all of our apps. There was no strong opinion on how to organize files and React is famously unopinionated when it comes to this, even more so with monorepos. We needed a solution.

Our new file structure

Disclaimer: the following structure developed due to specific quirks and constraints of our own codebase. Every project is different and there is no “right” way to organize things. Please use whatever best works for you.

Remember how we were copying and pasting entire files? We took that approach one step further and extracted the shared code to its own file which was then imported into its respective app file. The following folder structure emerged:

├── apps
│   ├── mobile: React Native app for iOS & Android
│   └── web: Web app
└── packages
    └── ui: Simple/generic cross platform UI components
    └── features: Complex platform-agnostic feature components

The features package contained platform agnostic files with shared business logic and UI elements, like that Profile from earlier. Each feature had its own folder with a main feature component and immediate supporting files.

├── features
│   └── Profile
|       ├── index.ts (should only export components used outside this folder)
|       ├── Profile.tsx (main file)
|       ├── Profile.spec.tsx (test)
|       ├── Reviews.tsx (<Profile /> child components)
│       └── useProfileQuery.ts (Profile related business logic can live here too)

One caveat: let’s say the Profile component is different enough to justify platform specific implementations. In this case, you could have separate files that live in the same folder but will be imported by their respective platforms.

Profile.web.tsx # Picked up by any web bundler
Profile.native.tsx # Picked up by Metro (React native bundler)

The web and mobile app folders became much leaner, and mainly contained app specific code like bootstrapping and navigation. Continuing with the Profile example, the mobile file now imports the feature component and injects platform specific dependencies defined in the mobile project.

// apps/mobile/src/Profile.tsx
import React from 'react';
import { Profile } from '@knack/features/Profile';

export const ProfileView = ({ navigation }) => {
  const id = navigation.params.id;
  const sendMessage = () => navigation.navigate('MessageView');

  return <Profile id={id} sendMessage={sendMessage} />;
};

TLDR: everything is either a feature or generic, reusable component

To summarize, all components fall into one of three categories:

UI

These components should be stateless, and define simple props with no knowledge of any backend API structure. They should ideally be cross platform, although sometimes it makes more sense to have .web and .native implementations that live next to each other for better maintainability.

  1. Primitives (Font, Button, Container, TextField, etc.)
  2. Generic reusable components built from primitives

Platform specific reusable components

This is more common on the mobile app than web. Sometimes you have components that really only make sense on web OR mobile ie. a file uploader, routing setup, or top level layout component.

Features

Everything that doesn’t fit in the above 2 categories is a feature. Features define UI hierarchy, complex data requirements, API interaction, and directly supporting business logic. They can be composed from UI components and/or other features!

A feature should be well defined, single purposed, and relatively small in scope. Think one parent component with a number of child components and supporting logic.

Features in action

Thanks to our new organizational approach, tutoring sessions, the bread and butter of our product, shares more than 90% of the exact same code. We even cut down on duplicate tests while significantly increasing our code coverage.

session comparison