Using redux with relational data (3/3)
Part 3. Putting all the pieces together
In this series of posts we will create an application using react and redux, in which we will handle relational data. In this third part we will be fetching data from an API and storing it in the store, caching the data and using it from different components.
We ended up the last part of this series implementing the store. Check part 2 if you need more context on that: Using redux with relational data (2/3).
Creating a DB and an API
For the sake of simplicity we will be using a JSON Server API pulling data from a JSON DB. Someone could argue that this is not a proper relational DB, but all the data therein will be relational, and so will be the data coming through the API. In other words, we will define objects in a single place, and will reference them by their ID.
The procedure to generate this data was explained in a former post:
Deploy a demo site with a JSON API on Heroku
The catch is that Faker.js is no longer supported by his author as you may be aware of, and thus you'll need to use this fork instead:
https://github.com/faker-js/faker
Consuming the API
We will create paginated API getters for each of our entities:
- user
- comment
- post
For the users API, the queries, pagination and OrderType
follow the API route definition provided by 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";
Post API will get paginated posts filtered by 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 };
Likewise, comments API will returned paginated comments filtered by 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 };
Calling the API and dispatching actions to store the data
The next thing to do is having a function that makes a call to the API followed by an action to store the data in the redux store. I prefer to keep them separate from the components, to make them easier to test.
I'll add a caching strategy, so that if a query was already made, it will just return the data instead of calling the endpoint again.
For the user
entity:
// 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 };
Similar functions are created for the post
and comment
entities.
Fetching data from components
Finally, we fetch the data from the components. We need to show a loading state until the data is ready and an error state if something went wrong fetching the data, as well as getting a new page of data when the user clicks on the pagination buttons.
The component doesn't need to take care of caching, it just calls transparently the functions in the commands files to fetch the data, and then get the data through selectors. If the data was cached, the page will load faster.
The friends page loads all the users:
// 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>
)}
</>
);
};
That is basically it. The rest of the application includes other components similar to this one, and the routing between them.
Recap
Let's summarize what was our objective with this series of posts and what we did:
- We wanted to implement an application that uses relational data in the backend, using redux in the frontend. We wanted to avoid data replication (having a single copy of each entity), and cache data already loaded to save bandwidth
- We implemented the redux store as a string indexed map, with external indexes to model the relations
- We implemented the api calls and the redux actions dispatches out of the components.
- We created loading and error states in the components
The following animation shows how the My Wall component loads all the data, starting from posts, which in turn trigger comments and users. All those are touching our backend endpoints dozens of times, and lots of loading states are shown. Of course, you might want to group some of those loading states in a real application, but the point here is that each network call is done just once. If a user has more than one comment or post, we are not loading the user again, because it is already in the store. If we navigate back and forth to some different page, when we return to the Wall we can already show all the data that was pulled without making new calls to the backend, and so on.
The following screenshot shows the store, and you can see that only a copy of the user is stored, for a user that has 2 comments.
This is a suitable scenario when your backend code provides RESTful CRUD services for each entity, since you are facing dozens of calls, that in turn trigger more and more calls when relational data is needed. A totally different approach may be using GraphQL, which could be the subject for a different post, but you don't always get to decide what will be the backend technology.
If you want to dig more into the code, remember that you can check the whole source code in this repository:
https://github.com/jguix/redux-normalized-example
I created a similar example here, including infinite scrolling pagination and an online demo served by Heroku.
This is the last post of the series. I hope you enjoyed it and if you have any comment, please drop your thoughts below in the comments section.
Credits
Photo by Fabrice Villard on Unsplash.