Usando redux con datos relacionales (2/3)
Parte 2. Implementando la store de redux
En esta serie de posts crearemos una aplicación usando react y redux, en la que manejaremos datos relacionales. En esta segunda parte modelaremos la store.
Al final de la anterior parte de esta serie habíamos modelado la tienda. Consulta la parte 1 si necesitas más contexto sobre ello: Usando redux con datos relacionales (1/3).
Nuestra tienda tendrá dos reducers principales, la store entities
y la store ui
.
Comencemos por crear la store entities
. Tendrá 3 tipos de datos o entidades, a saber:
- user
- comment
- post
Cada entidad tendrá asociados tipos, acciones y reducers. En aras de una fácil comprensión, mostraré los tipos y acciones en primer lugar.
Tipos de usuario:
// user.types.ts
export type User = {
avatar: string,
email: string,
id: number,
name: string,
};
Las acciones del usuario incluirán una acción para cargar todos los usuarios en la store y una acción para cargar un solo usuario. La primera será llamada potencialmente desde la página My Friends
, la segunda desde la página My Wall
o la página Friend Wall
, donde los posts y los comentarios mostrarán el usuario asociado junto a ellos.
// user.actions.ts
import { User } from './user.types';
export enum UserActionTypes {
LOAD_USER = 'USER:LOAD_USER',
LOAD_USERS = 'USER:LOAD_USERS',
}
export type LoadUserPayload = {
user: User;
};
export type LoadUserAction = {
type: UserActionTypes.LOAD_USER;
payload: LoadUserPayload;
};
const loadUserAction = (payload: LoadUserPayload): LoadUserAction => {
return {
payload,
type: UserActionTypes.LOAD_USER,
};
};
export type LoadUsersPayload = {
users: User[];
};
export type LoadUsersAction = {
type: UserActionTypes.LOAD_USERS;
payload: LoadUsersPayload;
};
const loadUsersAction = (payload: LoadUsersPayload): LoadUsersAction => {
return {
payload,
type: UserActionTypes.LOAD_USERS,
};
};
export const userActions = {
loadUserAction,
loadUsersAction,
};
De igual manera, tendremos tipos de post
, donde cada publicación tiene un userId
, que es la forma en que nuestra base de datos administrará la relación de uno a muchos (pero recuerda que haremos que estos datos sean más fáciles de buscar creando un reducer postIdsById
dentro del reducer users
):
// post.types.ts
export type Post = {
body: string,
date: Date,
id: number,
userId: number,
};
Las acciones de los post
solo incluyen una acción para cargar publicaciones por usuario, siendo el userId
un parámetro opcional. Enviaremos esta acción con el parámetro userId
informado desde la páginaFriend Wall
para obtener todas sus publicaciones. Despacharemos esta acción con el parámetro userId
con valor undefined
desde My Wall
para obtener todas las publicaciones de todos los usuarios (para simplificar, digamos que todos los usuarios son amigos míos).
// post.actions.ts
import { Post } from './post.types';
export enum PostActionTypes {
LOAD_POSTS = 'POST:LOAD_POSTS',
}
export type LoadPostsPayload = {
posts: Post[];
userId?: number;
};
export type LoadPostsAction = {
type: PostActionTypes.LOAD_POSTS;
payload: LoadPostsPayload;
};
const loadPostsAction = (payload: LoadPostsPayload): LoadPostsAction => {
return {
payload,
type: PostActionTypes.LOAD_POSTS,
};
};
export const postActions = {
loadPostsAction,
};
En cuanto a los tipos de comment
, contendrán índices que apuntan al post
y el user
relacionados:
// comment.types.ts
export type Comment = {
body: string,
date: Date,
id: number,
postId: number,
userId: number,
};
Las acciones de comment
también incluyen solo una acción para cargar comentarios por post:
// comments.actions
import { Comment } from './comment.types';
export enum CommentActionTypes {
LOAD_COMMENTS = 'COMMENT:LOAD_COMMENTS',
}
export type LoadCommentsPayload = {
comments: Comment[];
postId?: number;
};
export type LoadCommentsAction = {
type: CommentActionTypes.LOAD_COMMENTS;
payload: LoadCommentsPayload;
};
const loadCommentsAction = (payload: LoadCommentsPayload): LoadCommentsAction => {
return {
payload,
type: CommentActionTypes.LOAD_COMMENTS,
};
};
export const commentActions = {
loadCommentsAction,
};
Ahora, abordemos los reducers. En cuanto al reducer user
, se creará combinando dos reducers. El primero tomará la acción LoadUsersAction
y almacenará un mapa de usuarios porid
. También procesará la LoadUserAction
y almacenará al usuario en el mapa. El segundo tomará el LoadPostsAction
y almacenará un mapa de postIds
relacionados con un usuario.
// user.reducer.ts
import { User } from './user.types';
import { UserActionTypes, LoadUsersAction, LoadUserAction } from './user.actions';
import { NumberIndexed } from '../shared/shared.types';
import { AnyAction, combineReducers, Reducer } from 'redux';
import { LoadPostsAction, PostActionTypes } from '../post/post.actions';
export type UserState = {
byId: NumberIndexed<User>;
postIdsById: NumberIndexed<number[]>; // one-to-many relation
};
export type UserStore = {
users: UserState;
};
export const userByIdReducer = (state: NumberIndexed<User> = {}, action: AnyAction) => {
switch (action.type) {
case UserActionTypes.LOAD_USERS:
const { payload } = action as LoadUsersAction;
const { users } = payload;
const loadedUsersMap = users.reduce((map, user) => ({ ...map, [user.id]: user }), {});
return {
...state,
...loadedUsersMap,
};
case UserActionTypes.LOAD_USER:
const { payload: userPayload } = action as LoadUserAction;
const { user } = userPayload;
return {
...state,
[user.id]: user,
};
}
return state;
};
export const postIdsByIdReducer = (state: NumberIndexed<number[]> = {}, action: AnyAction) => {
switch (action.type) {
case PostActionTypes.LOAD_POSTS:
const { payload } = action as LoadPostsAction;
const { posts, userId } = payload;
let loadedPostIdsByUserIdMap = posts.reduce(
(postIdsByUserIdMap, post) => ({
...postIdsByUserIdMap,
[post.userId]: postIdsByUserIdMap[post.userId] ? [...postIdsByUserIdMap[post.userId], post.id] : [post.id],
}),
{} as NumberIndexed<number[]>
);
if (posts.length === 0) {
loadedPostIdsByUserIdMap = { [userId as number]: [] };
}
return {
...state,
...loadedPostIdsByUserIdMap,
};
}
return state;
};
export const userReducer: Reducer<UserState> = combineReducers({
byId: userByIdReducer,
postIdsById: postIdsByIdReducer,
});
El tipo personalizado NumberIndexed
se define de la siguiente manera, en un archivo compartido donde también definimos los tipos para los filtros. Este tipo nos permite tipar mapas con números como índices, utilizados por los reductores anteriores.
// shared.types.ts
export type NumberIndexed<T> = { [index: number]: T };
export type StringIndexed<T> = { [index: string]: T };
export type OrderType = "asc" | "desc";
De manera similar, el reducer post
tiene un reducer relacionado con la acciónLoadPost
y un reducer que se encarga de la LoadCommentsAction
.
// post.reducer.ts
import { Post } from './post.types';
import { PostActionTypes, LoadPostsAction } from './post.actions';
import { NumberIndexed } from '../shared/shared.types';
import { AnyAction, combineReducers, Reducer } from 'redux';
import { CommentActionTypes, LoadCommentsAction } from '../comment/comment.actions';
export type PostState = {
byId: NumberIndexed<Post>;
commentIdsById: NumberIndexed<number[]>; // one-to-many relation
};
export type PostStore = {
posts: PostState;
};
export const postByIdReducer = (state: NumberIndexed<Post> = {}, action: AnyAction) => {
switch (action.type) {
case PostActionTypes.LOAD_POSTS:
const { payload } = action as LoadPostsAction;
const { posts } = payload;
const loadedPostsMap = posts.reduce((map, post) => ({ ...map, [post.id]: post }), {});
return {
...state,
...loadedPostsMap,
};
}
return state;
};
export const commentIdsByIdReducer = (state: NumberIndexed<number[]> = {}, action: AnyAction) => {
switch (action.type) {
case CommentActionTypes.LOAD_COMMENTS:
const { payload } = action as LoadCommentsAction;
const { comments, postId } = payload;
let loadedCommentIdsByPostIdMap = comments.reduce(
(commentIdsByPostIdMap, comment) => ({
...commentIdsByPostIdMap,
[comment.postId]: commentIdsByPostIdMap[comment.postId]
? [...commentIdsByPostIdMap[comment.postId], comment.id]
: [comment.id],
}),
{} as NumberIndexed<number[]>
);
if (comments.length === 0) {
loadedCommentIdsByPostIdMap = { [postId as number]: [] };
}
return {
...state,
...loadedCommentIdsByPostIdMap,
};
}
return state;
};
export const postReducer: Reducer<PostState> = combineReducers({
byId: postByIdReducer,
commentIdsById: commentIdsByIdReducer,
});
El reducer de comment
es más simple, encargándose solo de la acciónLoadComments
.
// comment.reducer.ts
import { Comment } from './comment.types';
import { CommentActionTypes, LoadCommentsAction } from './comment.actions';
import { NumberIndexed } from '../shared/shared.types';
import { AnyAction, combineReducers, Reducer } from 'redux';
export type CommentState = {
byId: NumberIndexed<Comment>;
};
export type CommentStore = {
comments: CommentState;
};
export const commentByIdReducer = (state: NumberIndexed<Comment> = {}, action: AnyAction) => {
switch (action.type) {
case CommentActionTypes.LOAD_COMMENTS:
const { payload } = action as LoadCommentsAction;
const { comments } = payload;
const loadedCommentsMap = comments.reduce((map, comment) => ({ ...map, [comment.id]: comment }), {});
return {
...state,
...loadedCommentsMap,
};
}
return state;
};
export const commentReducer: Reducer<CommentState> = combineReducers({
byId: commentByIdReducer,
});
A continuación, implementaremos la store ui
. Contendrá los datos de la página My Wall
, Friend Wall
y Friends
.
My Wall
no contendrá tipos personalizados, solo índices a entidades de post
que pertenecen al usuario que se mostrarán en la página. Las acciones incluirán una acción para cargar posts del muro.
// wall.actions.ts
export enum WallActionTypes {
LOAD_POSTS = 'WALL:LOAD_POSTS',
}
export type LoadWallPostsPayload = {
postIds: number[];
};
export type LoadWallPostsAction = {
type: WallActionTypes.LOAD_POSTS;
payload: LoadWallPostsPayload;
};
const loadWallPostsAction = (payload: LoadWallPostsPayload): LoadWallPostsAction => {
return {
payload,
type: WallActionTypes.LOAD_POSTS,
};
};
export const wallActions = {
loadWallPostsAction,
};
El reducer será sencillo, encargándose solo de esa acción.
// wall.reducer.ts
import { AnyAction, combineReducers, Reducer } from 'redux';
import { LoadWallPostsAction, WallActionTypes } from './wall.actions';
export type WallState = {
postIds: number[];
};
export type WallStore = {
wall: WallState;
};
export const postIdsReducer = (state: number[] = [], action: AnyAction) => {
switch (action.type) {
case WallActionTypes.LOAD_POSTS:
const { payload } = action as LoadWallPostsAction;
const { postIds } = payload;
return [...state, ...postIds];
}
return state;
};
export const wallReducer: Reducer<WallState> = combineReducers({
postIds: postIdsReducer,
});
Omitiremos el código para las acciones y reducers asociados al Friend Wall
, que son muy similares a los de My Wall
. Puedes consultar la rama del repositorio de git para este post si deseas ver todo el código fuente.
Las acciones de Friends
incluirán cargar amigos y establecer el orden de la lista de amigos (ascendente o descendente).
// friends.actions.ts
import { OrderType } from '../shared/shared.types';
export enum FriendsActionTypes {
LOAD_FRIENDS = 'FRIENDS:LOAD_FRIENDS',
SET_FRIENDS_ORDER = 'FRIENDS:SET_FRIENDS_ORDER',
}
export type LoadFriendsPayload = {
userIds: number[];
};
export type LoadFriendsAction = {
type: FriendsActionTypes.LOAD_FRIENDS;
payload: LoadFriendsPayload;
};
const loadFriendsAction = (payload: LoadFriendsPayload): LoadFriendsAction => {
return {
payload,
type: FriendsActionTypes.LOAD_FRIENDS,
};
};
export type SetFriendsOrderPayload = {
order: OrderType;
};
export type SetFriendsOrderAction = {
type: FriendsActionTypes.SET_FRIENDS_ORDER;
payload: SetFriendsOrderPayload;
};
const setFriendsOrderAction = (payload: SetFriendsOrderPayload): SetFriendsOrderAction => {
return {
payload,
type: FriendsActionTypes.SET_FRIENDS_ORDER,
};
};
export const friendsActions = {
loadFriendsAction,
setFriendsOrderAction,
};
El reducer friends
tendrá reducers que solo apunten a las entidades user
. Tendremos uno para la lista con orden ascendente y otro para la lista con orden descendente, porque implementaremos una estrategia de paginación con el backend (de eso hablaremos en la próxima publicación de la serie). Otro reducer almacenará el estado del filtro.
// friends.reducer.ts
import { AnyAction, combineReducers, Reducer } from 'redux';
import { FriendsActionTypes, LoadFriendsAction, SetFriendsOrderAction } from './friends.actions';
export type FriendsState = {
orderFilter: 'asc' | 'desc';
userIds: number[];
};
export type FriendsStore = {
friends: FriendsState;
};
export const orderFilterReducer = (state: 'asc' | 'desc' = 'asc', action: AnyAction) => {
switch (action.type) {
case FriendsActionTypes.SET_FRIENDS_ORDER:
const { payload } = action as SetFriendsOrderAction;
const { order } = payload;
return order;
}
return state;
};
export const userIdsReducer = (state: number[] = [], action: AnyAction) => {
switch (action.type) {
case FriendsActionTypes.LOAD_FRIENDS:
const { payload } = action as LoadFriendsAction;
const { userIds } = payload;
return [...state, ...userIds];
case FriendsActionTypes.SET_FRIENDS_ORDER:
return [];
}
return state;
};
export const friendsReducer: Reducer<FriendsState> = combineReducers({
orderFilter: orderFilterReducer,
userIds: userIdsReducer,
});
Para crear la store, primero instalaremos la redux-devtools-extension. Con estas herramientas podremos depurar el despacho de acciones y los cambios en el estado de la store.
yarn add redux-devtools-extension
La root
store se compone de la store entities
y la store ui
de la siguiente manera:
// store.ts
import { combineReducers, createStore, Reducer } from "redux";
import { userReducer, UserStore } from "../modules/user/user.reducer";
import {
commentReducer,
CommentStore,
} from "../modules/comment/comment.reducer";
import { postReducer, PostStore } from "../modules/post/post.reducer";
import {
friendsReducer,
FriendsStore,
} from "../modules/friends/friends.reducer";
import {
FriendWallStore,
friendWallReducer,
} from "../modules/friend-wall/friend-wall.reducer";
import { wallReducer, WallStore } from "../modules/wall/wall.reducer";
import { composeWithDevTools } from "redux-devtools-extension";
export type EntitiesStore = CommentStore & PostStore & UserStore;
export type UIStore = FriendsStore & FriendWallStore & WallStore;
export type ApplicationStore = {
entities: EntitiesStore,
ui: UIStore,
};
export const entitiesReducer = combineReducers({
comments: commentReducer,
posts: postReducer,
users: userReducer,
});
export const uiReducer = combineReducers({
friends: friendsReducer,
friendWall: friendWallReducer,
wall: wallReducer,
});
export const rootReducer: Reducer<ApplicationStore> = combineReducers({
entities: entitiesReducer,
ui: uiReducer,
});
export const store = createStore(rootReducer, composeWithDevTools());
Finalmente, introduzcamos algunos datos en esta store, enviemos algunas acciones y veamos los resultados. Usaremos algunos datos simulados y mostraremos los resultados usando mensajes console.log
e imprimiendo el contenido de la store en la página principal. Alternativamente, puede depurar estas acciones con un complemento de Chrome como Redux DevTools.
// App.tsx
import React from "react";
import "./App.css";
import { store } from "./store/store";
import { userActions } from "./modules/user/user.actions";
import { User } from "./modules/user/user.types";
import { Post } from "./modules/post/post.types";
import { postActions } from "./modules/post/post.actions";
import { Comment } from "./modules/comment/comment.types";
import { commentActions } from "./modules/comment/comment.actions";
import { friendsActions } from "./modules/friends/friends.actions";
import { wallActions } from "./modules/wall/wall.actions";
import { friendWallActions } from "./modules/friend-wall/friend-wall.actions";
const users: User[] = [
{
id: 1,
name: "Josh Martin",
email: "josh.martin@gmail.com",
avatar: "http://placekitten.com/g/500/400",
},
{
id: 2,
name: "Emily Matthews",
email: "emily.matthews@gmail.com",
avatar: "http://placekitten.com/g/400/400",
},
{
id: 3,
name: "Sonia Lee",
email: "sonia.lee@gmail.com",
avatar: "http://placekitten.com/g/400/500",
},
];
const posts: Post[] = [
{ id: 1, body: "Blah", date: new Date(), userId: 1 },
{ id: 2, body: "Bleh", date: new Date(), userId: 1 },
{ id: 3, body: "Blih", date: new Date(), userId: 2 },
{ id: 4, body: "Bloh", date: new Date(), userId: 2 },
{ id: 5, body: "Bluh", date: new Date(), userId: 3 },
];
const comments: Comment[] = [
{ id: 1, body: "No", date: new Date(), postId: 1, userId: 2 },
{ id: 2, body: "Yes", date: new Date(), postId: 1, userId: 3 },
{ id: 3, body: "Yes!", date: new Date(), postId: 1, userId: 1 },
{ id: 4, body: "No!", date: new Date(), postId: 2, userId: 3 },
];
const App = () => {
store.subscribe(() => {
console.log("New state", store.getState());
});
console.log("Loading users");
store.dispatch(
userActions.loadUsersAction({
users,
})
);
console.log("Loading posts");
store.dispatch(
postActions.loadPostsAction({
posts,
})
);
console.log("Loading comments");
store.dispatch(
commentActions.loadCommentsAction({
comments,
})
);
console.log("Loading friends");
store.dispatch(
friendsActions.loadFriendsAction({
userIds: [2, 3],
})
);
console.log("Loading wall posts");
store.dispatch(
wallActions.loadWallPostsAction({
postIds: [1, 2, 3, 4, 5],
})
);
console.log("Loading Emily's posts");
store.dispatch(
friendWallActions.loadFriendWallPostsAction({
postIds: [3, 4],
userId: 2,
})
);
return (
<div className="App">
<div>Store contents</div>
<div>
<pre>{JSON.stringify(store.getState(), null, 2)}</pre>
</div>
</div>
);
};
export default App;
Si ejecutamos la aplicación, podemos seguir en la consola cómo la store despacha acciones y el resultado en la página generada. También podemos seguir los pasos, las actualizaciones parciales y el resultado en la extensión React DevTools.
Si deseas profundizar más en el código, recuerda que puedes consultar todo el código fuente en esta rama:
https://github.com/jguix/redux-normalized-example/tree/blogpost-part2
En la próxima publicación implementaremos las páginas y componentes y un backend simulado con paginación. También implementaremos métodos de almacenamiento en caché para evitar pedir los mismos datos una y otra vez.