Usando redux con datos relacionales (3/3)
Parte 3. Juntando todas las piezas
En esta serie de posts, crearemos una aplicación usando react y redux, en la que manejaremos datos relacionales. En esta tercera parte, cargaremos datos de una API y los almacenaremos en la store, usaremos estrategias de cacheado de datos y utilizaremos los datos de la store en diferentes componentes.
Acabamos la anterior parte de esta serie implementando la store. Consulta la parte 2 si necesitas más contexto al respecto: Usando redux con datos relacionales (2/3).
Creando una BD y una API
En aras de la simplicidad, utilizaremos una API de JSON Server, extrayendo los datos de una base de datos JSON. Alguien podría argumentar que esta no es una base de datos relacional adecuada, pero todos los datos que contiene serán relacionales, y también lo serán los datos que lleguen a través de la API. En otras palabras, definiremos los objetos en un solo lugar y los referenciaremos por su ID.
El procedimiento para generar estos datos fue explicado en un post anterior:
Desplegar una demo con una API JSON en Heroku
El problema es que Faker.js ya no es soportado por su autor como posiblemente sabrás, por lo que deberás usar este fork en su lugar:
https://github.com/faker-js/faker
Consumiendo el API
Crearemos getters API paginados para cada una de nuestras entidades:
- user
- comment
- post
Para la API de usuarios, las consultas, la paginación y el OrderType
siguen la definición de API routes proporcionada por JSON Server:
// user.api.ts
import { User } from "./user.types";
import { OrderType } from "../shared/shared.types";
const loadUsers = (
page: number,
limit: number,
order: OrderType
): Promise<User[]> => {
return fetch(`/users?${getUsersQuery(page, limit, order)}`).then((response) =>
response.json()
);
};
const loadUser = (userId: number): Promise<User> => {
return fetch(`/users/${userId}`).then((response) => response.json());
};
const getUsersQuery = (page: number, limit: number, order: OrderType) =>
`_page=${page}&_limit=${limit}&_sort=name&_order=${order}`;
export const userApi = { getUsersQuery, loadUser, loadUsers };
// shared.types.ts
export type NumberIndexed<T> = { [index: number]: T };
export type StringIndexed<T> = { [index: string]: T };
export type OrderType = "asc" | "desc";
La API de posts estará paginada y filtrada por userId
:
// post.api.ts
import { Post } from "./post.types";
const loadPosts = (
page: number,
limit: number,
userId?: number
): Promise<Post[]> => {
return fetch(`/posts?${getPostsQuery(page, limit, userId)}`).then(
(response) => response.json()
);
};
const getPostsQuery = (page: number, limit: number, userId?: number) =>
`_page=${page}&_limit=${limit}${userId ? `&userId=${userId}` : ""}`;
export const postApi = { getPostsQuery, loadPosts };
De la misma forma, la API de comments devolverá comments paginados y filtrados por postId
:
// comment.api.ts
import { Comment } from "./comment.types";
const loadComments = (postId: number): Promise<Comment[]> => {
return fetch(`/comments${getCommentsQuery(postId)}`).then((response) =>
response.json()
);
};
const getCommentsQuery = (postId?: number) =>
`${postId ? `?postId=${postId}` : ""}`;
export const commentApi = { getCommentsQuery, loadComments };
Llamando a la API y lanzando acciones para almacenar los datos
Lo siguiente que se debe hacer es tener una función que realice una llamada a la API seguida de una acción para almacenar los datos en la store de redux. Prefiero mantenerlos separados de los componentes, para que sean más fáciles de testear.
Agregaré una estrategia de almacenamiento en caché, de modo que si ya se realizó una consulta, solo devolverá los datos en lugar de volver a llamar al endpoint.
Para la entidad user
:
// user.commands.ts
import { userApi } from "./user.api";
import { store } from "../../store/store";
import { userActions } from "./user.actions";
import { OrderType } from "../shared/shared.types";
const loadUser = (
userId: number,
invalidateCache: boolean = false
): Promise<number> => {
return new Promise((resolve, reject) => {
if (!invalidateCache && isUserDataCached(userId)) {
resolve(getCachedUserId(userId));
} else {
userApi.loadUser(userId).then(
(user) => {
store.dispatch(
userActions.loadUserAction({
user,
})
);
resolve(user.id);
},
(error) => {
console.log(error);
reject();
}
);
}
});
};
const loadUsers = (
page: number = 1,
limit: number = 5,
order: OrderType = "asc",
invalidateCache: boolean = false
): Promise<number[]> => {
return new Promise((resolve, reject) => {
if (!invalidateCache && isUsersDataCached(page, limit, order)) {
resolve(getCachedUserIds(page, limit, order));
} else {
userApi.loadUsers(page, limit, order).then(
(users) => {
const userIds = users.map((user) => user.id);
store.dispatch(
userActions.loadUsersAction({
users,
})
);
store.dispatch(
userActions.cacheUsersAction({
userIds,
page,
limit,
order,
})
);
resolve(userIds);
},
(error) => {
console.log(error);
reject();
}
);
}
});
};
const isUsersDataCached = (
page: number,
limit: number,
order: OrderType
): boolean => getCachedUserIds(page, limit, order) !== undefined;
const isUserDataCached = (userId: number): boolean =>
getCachedUserId(userId) !== undefined;
const getCachedUserIds = (page: number, limit: number, order: OrderType) => {
const usersQuery = userApi.getUsersQuery(page, limit, order);
return store.getState().entities.users.cachedUserIds[usersQuery];
};
const getCachedUserId = (userId: number) =>
store.getState().entities.users.byId[userId]?.id;
export const userCommands = { loadUser, loadUsers };
Se han creado funciones similares para las entidades post
y comment
.
Obteniendo datos desde los componentes
Finalmente, obtenemos los datos desde los componentes. Necesitamos mostrar un estado de carga hasta que los datos estén listos y un estado de error si algo salió mal al obtener los datos, así como cargar una nueva página de datos cuando el usuario hace clic en los botones de paginación.
El componente no necesita ocuparse del almacenamiento en caché, solo llama de forma transparente a las funciones en los archivos de comandos para obtener los datos y luego obtiene los datos a través de selectores. Si los datos se almacenaron en caché, la página se cargará más rápido.
La página de friends carga todos los usuarios:
// friends.component.tsx
import React, { FC, useState, useEffect, ChangeEvent } from 'react';
import { useSelector } from 'react-redux';
import { User } from '../../user/user.types';
import { ApplicationStore } from '../../../store/store';
import { Link } from 'react-router-dom';
import { OrderType } from '../../shared/shared.types';
import { friendsCommands } from '../friends.commands';
import { userCommands } from '../../user/user.commands';
const LIMIT = 5;
export const RnFriends: FC = () => {
const order = useSelector<ApplicationStore, OrderType>((state) => {
return state.ui.friends.orderFilter;
});
const friends = useSelector<ApplicationStore, User[]>((state) => {
const userIds = state.ui.friends.userIds;
return userIds?.map((userId) => state.entities.users.byId[userId]);
});
const currentPage = Math.ceil(friends?.length / LIMIT);
const [isLoading, setLoading] = useState(false);
const [isError, setError] = useState(false);
const [page, setPage] = useState(currentPage);
useEffect(() => {
if (page === 0) {
incrementPage();
}
}, []);
useEffect(() => {
if (page !== currentPage) {
onPageChange();
}
}, [currentPage, page]);
const incrementPage = () => setPage(currentPage + 1);
const onPageChange = () => {
setLoading(true);
userCommands
.loadUsers(page, LIMIT, order)
.then((userIds) => friendsCommands.loadFriends(userIds))
.then(
() => setLoading(false),
() => setError(true)
);
};
const onOrderChange = (event: ChangeEvent<HTMLSelectElement>) => {
setPage(1);
friendsCommands.setOrder(event.target.value as OrderType);
};
if (isError) {
return <div>Error loading friends, please refresh page.</div>;
}
return (
<>
<h1>My Friends</h1>
{friends?.length > 0 && (
<div>
<span>Order: </span>
<select onChange={onOrderChange} value={order}>
<option value="asc">Ascendent</option>
<option value="desc">Descendent</option>
</select>
<span> </span>
<button onClick={incrementPage}>Load next 5</button>
<hr />
</div>
)}
{friends.map((friend: User) => (
<Link key={friend.id} to={`/friend/${friend.id}`}>
<div>{friend.name}</div>
</Link>
))}
{isLoading && <div>Loading friends...</div>}
{friends?.length > 0 && (
<div>
<hr />
<button onClick={incrementPage}>Load next 5</button>
</div>
)}
</>
);
};
Eso es básicamente todo. El resto de la aplicación incluye otros componentes similares a este y el enrutado entre ellos.
Recapitulando
Resumamos cuál era nuestro objetivo con esta serie de publicaciones y lo que hicimos:
- Queríamos implementar una aplicación que use datos relacionales en el backend, usando redux en el frontend. Queríamos evitar la replicación de datos (tener una sola copia de cada entidad) y cachear datos ya cargados para ahorrar ancho de banda.
- Implementamos la store de redux como un mapa indexado mediante strings, con índices externos para modelar las relaciones.
- Implementamos las llamadas a la API y los dispatch de acciones de redux fuera de los componentes.
- Creamos estados de carga y error en los componentes
La siguiente animación muestra cómo el componente My Wall carga todos los datos, a partir de los posts, que a su vez se trean los comentarios y usuarios relacionados. Todas esas llamadas están tocando nuestros endpoints de backend docenas de veces, y se muestran muchos estados de carga. Por supuesto, sería posible agrupar algunos de esos estados de carga en una aplicación real, pero el punto aquí es que cada llamada de red se realiza solo una vez. Si un usuario tiene más de un comentario o publicación, no volvemos a cargar al usuario, porque ya está en la store. Si navegamos de un lado a otro a alguna página diferente, cuando volvamos a My Wall, podemos mostrar todos los datos que ya se cargaron sin hacer nuevas llamadas al backend, y así sucesivamente.
La siguiente captura de pantalla muestra la store, y puedes ver que solo se almacena una copia del usuario, para un usuario que tiene 2 comentarios.
Este es un escenario adecuado cuando tu código de backend proporciona servicios RESTful CRUD para cada entidad, ya que te enfrentas a docenas de llamadas, que a su vez desencadenan más y más llamadas cuando se necesitan datos relacionados. Un enfoque totalmente diferente puede ser el uso de GraphQL, lo cual podría ser el tema de una publicación diferente, pero no siempre puedes decidir cuál será la tecnología de backend.
Si quieres profundizar más en el código, recuerda que puedes consultar el código fuente completo en este repositorio:
https://github.com/jguix/redux-normalized-example
Creé un ejemplo similar aquí, que incluye paginación con scroll infinito y una demo online servida por Heroku.
Este es el último post de la serie. Espero que lo hayas disfrutado y si tienes algún comentario, no dudes en compartirlo más abajo en la sección de comentarios.
Créditos
Foto por Fabrice Villard en Unsplash.