Using redux with relational data (3/3)

January 31, 2022

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:

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>&nbsp;</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:

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.

Loading state at My Wall

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.

Redux store state

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.

About the author: Juangui Jordán

Full-stack engineer at mimacom, I'm moving towards frontend and UX. Always keen to learn and teach whatever I have to offer to others.

Comments
Join us