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 isuseX
whereX
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 callmutate("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>