Article
View as MarkdownHow I Organize React Applications
By Sergio Xalambrí ·
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
getXand the Hook isuseXwhereXis the data I'm queryingThe
keyis not the URL of the endpoint, instead I use a meaningfull name, this makes it easier to later callmutate("current-user")to revalidate it or mutate itThe 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
pagesfolder.
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>