How I Organize React Applications

When using React one of the most common question is how to organize the files inside it. In my years using it I have tried multiple options, including using feature folders, folders per components, one component per file and more, after trying them all, I finally found one that is simple and scale with big projects at the same time.

src/components/
  {name}.tsx
  {name}.test.tsx
src/hooks
  {name}.ts
  {name}.test.tsx
src/mutations/
  {name}.ts
  {name}.test.tsx
src/queries/
  {name}.ts
  {name}.test.tsx
src/routes/
  {name}.tsx
  {name}.test.tsx
src/utils
  {name}.ts
  {name}.test.ts
src/index.tsx               # The entry point

🧱 Components

The first folder is the components folder, this one contains the components of the application. One special thing I do that most React developers don't is that I don't create a new file per React component, this means in the same file I can have more than one React component, in a normal file inside components I wrote things like:

import * as React from "react";
// Possible more imports here

function ListItem(props) { ... }

function List(props) { ... }

function Group(props) { ... }

function Button(props) { ... }

export default function MyComponent() { ... }

This way, all the component I create are in the same file, this make it easier to change them, this doesn't mean all the components are always in the same file, I move components to a new file in some cases

  • I want to lazily import them, in that case I need a new file to import
  • The component is used by another component in a different file
  • The component is used by two or more routes
  • The component is too complex and it makes sense to test it in isolation
  • The component represents a whole feature (usualy this is related to the point above)

And I create a new component inside the same file if

  • I need to re-use it inside the same file
  • I need to use it inside a list and call hooks per each item
  • I want to use hooks only related for that part of the main feature of the file
  • I want to suspend the component, because I use suspense for data fetching, without suspending the parent component
  • The component its getting to big and it makes sense to split it to make it easy to read it

For the tests, I create a single test file per component and put all the tests I wrote there, to write them I use React Testing Library and I follow all the best practices they recommend.

⚓️ Hooks

I create custom hooks everytime I need to share behavior between components.

An example of a Hook I usually create are things like useAsset combined a Context provider to share asset URLs or useBoundingClientRect to share a generic behavior.

And talking about Context, I still use it, but only when the value stored in the Context is mostly static, e.g. feature flags or assets), this way I don't have issues because the Context value changed and triggered a re-render in most of the application.

As with components, I test the hooks, to do it I create a simple Tester component using the hook in the test file and I test the component directly.

🧬 Mutations

A mutation is a special kind of Hook, a mutation represent a single unit of change the user can perform in an application in a single action, they are wrapper of useMutation, a tiny Hook I created based on the useMutation Hook of React Query.

As an example I tend to have mutations like useUploadAvatar or useCreateTodo, a mutation could represent a really simple change or a complex one, the idea is that the user perform it as an individual action.

Note that a mutation doesn't match the HTTP methods POST/PUT/PATCH/DELETE, they are not like a CRUD wrapper (e.g. useCreateTodo, useUpdateTodo and useDeleteTodo), while I can still create mutations with those names I also have mutations with names like useCompleteTodo which is calling a PATCH against the todos endpoints and only update a certain field.

Example of what I don't do:

import useUpdateTodo from "mutations/use-update-todo";

function Todo() {
  const [updateTodo] = useUpdateTodo();

  function handleCompleteClick() {
    updateTodo({ id: 1, completed: true });
  }

  // return some UI
}

That looks generic, I could use the same mutation to update any field of the Todo resource, instead I do it this way:

import useCompleteTodo from "mutations/use-complete-todo";

function Todo() {
  const [completeTodo] = useCompleteTodo();

  function handleCompleteClick() {
    completeTodo({ id: 1 });
  }

  // return some UI
}

This is more specific, the mutation will only update the completed field of the Todo resource with the ID I've specified, and that's all.

Another thing I do in these mutations is to add the optimistic update of the data client-side, if possible since somethings it's not, and implement a rollback in case it failed together with a revalidation after a success.

This is a final example of how I create my mutations

import { mutate, cache } from "swr";
import useMutation from "use-mutation";
import { Todo } from "types/schema";

type CompleteTodoInput = { id: number };
type CompleteTodoOutput = Todo;

async function completeTodo(
  input: CompleteTodoInput
): Promise<CompleteTodoOutput> {
  const res = fetch(`/api/todos/${id}`, {
    method: "POST",
    body: JSON.stringify({ completed: true }),
  });
  if (!res.ok) throw new Error(res.statusText);
  return (await res.json()) as CompleteTodoOutput;
}

export function useCompleteTodo() {
  return useMutation(completeTodo, {
    onMutate({ input }) {
      const key = ["todos", input.id];
      const old = cache.get(key);

      // optimistically update cached data
      mutate(
        key,
        (todo) => {
          return { ...todo, completed: true };
        },
        false
      );

      // rollback optimistic update
      return () => mutate(key, old);
    },

    onSuccess({ input }) {
      // revalidate
      mutate(["todos", input.id]);
    },

    onFailure({ rollback }) {
      // rollback in case of failure
      if (rollback) rollback();
    },
  });
}

🔍 Queries

A query is another special kind of Hook, in this case their only purpose is to wrap SWR and provide the required key and fetcher function. Usually a Hook inside this folder will looks like this:

import useSWR, { ConfigInterface } from "swr";

// fetcher
async function getCurrentUser() {
  const res = await fetch("/api/me");
  return await res.json();
}

export function useCurrentUser(config = {}: ConfigInterface) {
  return useSWR("current-user", getCurrentUser, { suspense: true, ...config });
}

As you can see, the code here is usually small, a few notable points:

  • The fetcher is called as getX and the Hook is useX where X is the data I'm querying
  • The key is not the URL of the endpoint, instead I use a meaningfull name, this makes it easier to later call mutate("current-user") to revalidate it or mutate it
  • The Hook receives all the configuration options of SWR and pass it to useSWR, this let me control the configuration in a per-instance way.
  • SWR is, by default, configured to enable Suspense for Data-Fetching, I always use it this way, but I can still disable it in some cases passing a custom config.

Another things to remark here is that I usually don't test queries, this is because most of the code is a simple fetch whose test will be mocked anyway making it useless the Hook is just a wrapper of SWR.

🗺️ Routes

Note: When using Next.js this is replaced by the pages folder.

Routes is a special components folder, this follow the same rules I use for components inside src/components, the only difference is that they represent a single route of my application.

The files here must always have a single export default, this is required to be able to lazily import the routes in the entry point which is where I define my routes.

These components are sometimes not importing a single component from the components folder, because they implement the whole features the page needs, and when I import external components I try to do it lazily too.

Here I also add tests, but in this case I do more integration tests rather and unit tests, this is because I'm testing the whole page with all the features inside it.

🔨 Utils

I create utility functions all the time, it helps me name piece of logics or simple make it easier to understand what is happening, specially when there are multiple conditions because I can do things like:

function getSomething() {
  if (condition) return value;
  if (anotherCondition) return anotherValue;
  return yetAnotherValue;
}

However, I don't always create them in a file inside src/utils, I first write them inside the same file it needs them, this could be a hook, component, route, or even another utility, but if my utility function is used in three or more files then I move it to a file inside the utils folder to avoid duplicating it (WET).

I also write test for the utils I code only when they are large or complex enough to need it, and when they are not only wrappers of another function, specially if they wrap a browser API.

🚪 Entry Point

Finally, the entry point is where I lazily import all the routes, I import the context providers I may need, and I define all my routes and render the whole application.

I don't really create an App component because most of the time I can directly render the Router and Route components without wrapping them in an App.

When using Next.js, this is the pages/_app.tsx file.

🖼️ Appendix: Assets

When using assets, I don't like to import them in my code, this makes the build process way slower and the only benefit, have hashes in the assets name, I can gain it using other specialized tools. In my case I work with Rails as a backend, thus I let Rails handle static assets and I use the Rails view to pass the URLs of those assets to React adding a script of type application/json with a JSON containing the URLs.

<script type="application/json" id="initial-props">
  {
    "assets": {
      "logo": <%= asset_path("images/logo.png") %>
    }
  }
</script>