Como organizo mis aplicaciones de React
Una de las preguntas más comunes cuando se usa React es como organizar los archivos de nuestra aplicación. Con los años usé varias formas, tener carpetas por features, carpetas por componente, un componente por archivo, etc. Después de probar todas estas formas llegué a una suficientemente simple y escalable para proyectos grandes.
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 # El entry point
🧱 Componentes
La primera carpeta es la de componentes, acá coloco los componentes de la aplicación. Una cosa que hago diferente de muchos desarrolladores es que no creo un nuevo archivo por cada componente de React, sino que en un archivo normalmente escribo varios componentes, algo como esto:
import * as React from "react";
// Otros imports que pueda necesitar
function ListItem(props) { ... }
function List(props) { ... }
function Group(props) { ... }
function Button(props) { ... }
export default function MyComponent() { ... }
De esta forma todos los componentes están en el mismo archivo, esto hace mucho más fácil modificarlos, sin embargo, no quiere decir que solo tengo un archivo con todos mis components, creo un nuevo archivo en estos casos:
- Quiero importarlos de forma async, en ese caso necesito un nuevo archivo
- El componente se usa en varios archivos
- El componente es usado por dos o más rutas
- El componente es lo suficientemente complejo para que tenga sentido que tenga pruebas propias
- El componente representa un feature entero (normalmente relaciondo al punto anterior)
Y creo un nuevo componente en el mismo archivo si:
- Necesito reusarlo dentro del mismo archivo
- Voy a usarlo dentro de una lista y tengo que usar Hooks
- Quiero usar Hooks solo cierta parte del componente principal
- Quiero poder controlar como el componente se suspende, ya que uso Suspense para data fetching, pero sin suspende al componente padre
- El componente se volvió muy grande y tiene sentido dividirlo para que sea más legible
Para el caso de las pruebas, creo un único archivo por componente y junto todas las pruebas ahí, para escribirlas uso React Testing Library y sigo todas las prácticas que recominendan.
⚓️ Hooks
Creo Hooks custom cada vez que necesito compartir comportamiento entre componentes.
Un ejemplo de un Hook que normalmento creo es useAssets
combinado con un ContextProvider para compartir las URLs de los assets o useBoundingClientRect
para compartir comportamiento genérico.
Y hablando de contexto, lo uso, pero solo para valores mayormente estático (e.g. feature flags o assets), esto me evita problamas de que al modificar el valor en contexto se vuelva a renderizar casi toda mi aplicación.
Como con los componentes, hago pruebas de mis Hooks, para esto creo un componente Tester dentro de los tests donde uso el hook y testeo ese componente directamente.
🧬 Mutaciones
Una mutación es un tipo especial de Hook, las mutaciones representan una única unidad de cambio que el usuario puede realizar en la aplicación al realizar alguna acción, son wrappers de useMutation, un Hook pequeño que cree basado en el Hook del mismo nombre de React Query.
Como ejemplo, normalmente tiendo a tener mutaciones como useUploadAvatar
o useCreateTodo
, una mutación puede representar un cambio pequeño o complejo, la idea es que el usuario lo haga como una única acción.
Es importante destacar que no tienen que igualar los método HTTP POST/PUT/PATCH/DELETE, las mutaciones no son simples wrappers de un CRUD (por ejemplo useCreateTodo
, useUpdateTodo
y useDeleteTodo
), aunque algunas veces creo mutaciones con esos nombres, más tiendo a tener nombres como useCompleteTodo
, que si bien hace una operación PATCH sobre el endpoint de Todos, solo se encarga de un cambio de este recurso, no puede modificar otra cosa.
Ejemplo de que no hago:
import useUpdateTodo from "mutations/use-update-todo";
function Todo() {
const [updateTodo] = useUpdateTodo();
function handleCompleteClick() {
updateTodo({ id: 1, completed: true });
}
// retorna alguna UI
}
Es demasiado genérico, podría usar la misma mutación para actualizar cualquier campo del recurso Todo, en vez de eso esto es lo que normalmente hago:
import useCompleteTodo from "mutations/use-complete-todo";
function Todo() {
const [completeTodo] = useCompleteTodo();
function handleCompleteClick() {
completeTodo({ id: 1 });
}
// retorna alguna UI
}
Este es mucho más específico, la mutación solo va a actualizar el campo completed
del recursos Todo con el ID que especifique, y eso es todo.
Otra cosa que normalmente hago en estas mutaciones es agregar actualizaciones optimisticas a la información que tengo en el cliente, si es que es posible ya que algunas veces (ej. al crear un recurso) no lo es o es más difícil. También implemento un rollback para deshacer los cambios en caso de que la mutación falle junto a una revalidación en caso de éxito.
Este es el ejemplo final de como queda una mutación:
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);
// actualizo la cache de forma optimista
mutate(
key,
(todo) => {
return { ...todo, completed: true };
},
false
);
// función para deshacer los cambios
return () => mutate(key, old);
},
onSuccess({ input }) {
// revalido
mutate(["todos", input.id]);
},
onFailure({ rollback }) {
// deshago los cambios en caso de error
if (rollback) rollback();
},
});
}
🔍 Queries
Una query es otro tipo especial de Hook, en este caso su único propósito es hacer envolver a SWR y pasarle la key y la función fetcher. Usualmente un Hook dentro de esta carpeta se ve algo así:
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 });
}
Como se puede ver, el código es bastante pequeño, algunos a destacar son:
- El fetcher se llama
getX
y el HookuseX
, dondeX
es la data que estoy pidiendo del API. - La
key
no es la URL del endpoint, en vez de eso tiene un nombre con significado, esto hace más fácil que luego llamemos amutate("current-user")
para revalidad o mutar esta data. - El Hook recibe todas las opciones de configuración de SWR y se las pasa a useSWR, esto me deja cambiar la configuración en cada lugar que uso el Hook.
- A SWR los configuro por defecto para activar Suspense para Data-Fetching, esto es porque siempre lo uso de esta forma, pero permito desactivarlo pasando una configuración propia.
Una última cosa a destacar es que usualmente no agrego pruebas a mis queries, esto es porque la mayoría del código es un simple fetch que al hacerle pruebas terminaría mockeando haciendo inútil la prueba.
🗺️ Rutas
Nota: Cuando uso Next.js esto es reemplazado por la carpeta
folders
.
Rutas es una carpeta de componentes especial, sigue las mismas reglas que uso para crear componentes en src/components
, con la única diferencia de que cada componente dentro de rutas representa una ruta de mi aplicación.
Los archivos dentro de esta carpeta deben tener siempre un export default
, esto es necesario para poder importar de forma asíncrona las rutas en el entry point, que es donde defino las rutas.
Estos componentes a veces no importan nada de la carpeta de components, esto pasa cuando la ruta tiene todo el código que necesita. Cuando necesito importar componentes externos normalmente intento hacerlo de forma asíncrona.
También escribo pruebas para mis rutas, en su caso tiendo a hacer pruebas de integración más que pruebas unitarias, esto es porque se incluyen varios features.
🔨 Utils
Creo funciones utilitarias todo el tiempo, me ayudan a nombrar piezas de lógica o simplement hacer más fácil de entender que está ocurriendo, especialmente cuando tiene múltiples condiciones, ya que puedo hacer algo como:
function getSomething() {
if (condition) return value;
if (anotherCondition) return anotherValue;
return yetAnotherValue;
}
Sin embargo, no siempre las creo dentro de src/utils
, primero las escribo dentro dentro del mismo archivo donde las necesito, que puede ser un hook, components, ruta o incluso otro util, una vez que un util es usado en tres o más archivo lo muevo a su propio archivo para evitar duplicados (WET).
También escribo pruebas para mis funciones utilitarias cuando estas son bastante largas o suficientemente complejas para necesitarlas, aunque cuando son solo wrappers de otra función o de una API de los navegadores evito agregarle pruebas.
🚪 Entry point
Finalmente, el entry point es donde importo de forma asíncrona todas mis rutas y donde importa los proveedores de context que puedas necesitas, acá defino todas las rutas y renderizo la aplicación.
Normalmente no creo un componente App ya que en la mayoría de casos con solo renderizar Router y las rutas es suficientemente para que la aplicación funcione.
Cuando uso Next.js, esto es reemplazado por pages/_app.tsx
.
🖼️ Apéndice: Assets
Cuando uso assets evito importarlos directo en mi código, esto hace el build mucho más lento y el único beneficio, agregar hashes a los nombres de los assets, lo puedo lograr con otras herramientas especializadas. En mi caso trabajo con Rails como backend, así que dejo que este maneje mis assets y uso las vistas de Rails para pasar las URLs de esos assets a React agregando una etiqueta script de tipo application/json
con un JSON con las URLs.
<script type="application/json" id="initial-props">
{
"assets": {
"logo": <%= asset_path("images/logo.png") %>
}
}
</script>