Jay Gould

Making a simple rating system with React Native

September 29, 2021

Following on from this post on my Cinepicks app, I wanted to show how I implemented the 5 star rating system. Before going into the technical detail, here’s a quick overview of the rating system.

Cinepicks rating system

The Cinepicks app allows users to find out which streaming service they can watch TV shows and films on. Aside from that main feature, the app is also a place that users can rate those TV shows and films, with the ability to go back through an ordered list of previously rated titles.

Cinepicks rating

The ratings use a simple 5 star system, with the stars updating from just an outline to a filled in yellow colour based on the rating.

The front end

The UI is done with React Native using a simple component with local state using hooks:

const StarEmpty = require("./assets/star-empty.png")
const StarFilled = require("./assets/star-filled.png")

const StarRating = (title) => {
  const [userRating, setUserRating] = useState(null)

  const submitRating = (rating) => {
    // ...
  }

  return (
    <View
      style={{
        flexDirection: "row",
        justifyContent: "center",
      }}
    >
      {Array(5)
        .fill()
        .map((v, i) => {
          return (
            <TouchableOpacity
              key={i}
              style={{
                padding: 10,
              }}
              onPress={() => {
                setUserRating(i + 1)
                submitRating(i + 1)
              }}
            >
              <Image
                style={{
                  width: 20,
                  height: 20,
                  resizeMode: "contain",
                }}
                source={userRating > i ? StarFilled : StarEmpty}
              />
            </TouchableOpacity>
          )
        })}
    </View>
  )
}

We know there will always be 5 stars, so we start off by creating an array of 5 blank elements in order to map over 5 times. Each array element has an index value (i), which is stored in userRating state with the use of setUserRating when a star element is tapped. Then, depending on the number in this state, each image conditionally shows either a filled or empty image with source={userRating > i ? StarFilled : StarEmpty}. This process means the stars will be filled up to the rating that the user selects.

As well as handling the UI effect of showing what rating is currently selected, we also want to send this to the server to store and persist the rating for the user. Without storing the rating, the user could tap the back button and visit the title page again, and the rating will be empty.

In order to send the data to the server, the submitRating function is fired on each tap, at the same time as the setUserRating function covered above:

const submitRating = (rating) => {
  return fetch(`${config.apiUrl}/v1/titles/rate-title`, {
    method: "POST",
    headers: config.jsonHeaders({ jwt: userInfo.jwt }),
    body: JSON.stringify({
      rating,
      imdbId: title.imdbId,
    }),
  })
    .then((response) => response.json())
    .then((resp) => {})
    .catch(() => {
      setUserRating(null)
      toastMessage("error", "There was a problem submitting your data.")
    })
}

The submitRating server request and setUserRating local state update are both done at the same time for a faster feeling UI. A traditional way to handle this might be to send the request to the server and wait for a response before updating the stars with the users selected rating, however this would mean a short delay in the UI updating whilst the server request is processing. The chance of this simple server request failing is so low, so we can assume it will pass, and update the UI to remove the user rating if the request fails. This paradigm is called Optimistic UI Updates, and is a great way to make an app feel lightning fast to users.

The back end

The back end of Cinepicks uses Express on Node JS with a Postgres database. When a user taps a star the submitRating function on the front end is executed which sends a POST request to the rate-title endpoint on the server:

import { TitleRatings } from "../services/TitleRatings"

router.post("/rate-title", (req, res) => {
  const rating: any = req.body.rating || null
  const imdbId: any = req.body.imdbId || null
  const thisUser: any = req.thisUser

  if (!imdbId || !rating) {
    return errors.errorHandler(
      res,
      "You must select a title and provide a rating.",
      null
    )
  }

  const titleRatings: any = new TitleRatings()

  titleRatings
    .rateTitle(imdbId, rating, thisUser.id)
    .then((response: any) => {
      return res.send({
        success: true,
        response,
      })
    })
    .catch((err: any) => {
      return errors.errorHandler(res, err.message, null)
    })
})

The route gets the three data points from the request:

  • The rating, sent as an integer in the POST body
  • The imdbId, sent as a string in the POST body
  • The user ID, retrieved from the JWT in some middleware earlier in the request

The IMDB ID is the unique identifier of the title the user is rating. This value exists for all TV shows and movies in the app, and is useful as all APIs I get data from accept an IMDB ID as a parameter.

Then using a TitleRating service, the main process of storing the user rating is executed:

import db from '../db/models';

class TitleRatings {
  constructor() {}

  public async rateTitle(imdbId, rating, userId) {
    const hasEntry = await db.user_titles.findOne({ where: { imdbId, userId } });
    try {
      if (hasEntry) {
        const updated = await db.user_titles.update(
          { watchedRating: rating },
          {
            where: {
              imdbId,
              userId
            },
            returning: true,
            plain: true,
            raw: true
          }
        );

        return updated[1];
      } else {
        return await db.user_titles.create({
          imdbId,
          watchedRating: rating,
          userId
        });
      }
    } catch (e) {
      return false;
    }
  }
}

The rateTitle function adds the users rating to the database. This includes saving the user ID, IMDB ID, and rating. The system does an upsert based on the user ID and IMDB ID - ensuring that if the user ID and IMDB ID both exist in a row, the rating will be updated, otherwise it will be saved as a new row.

Later, when the rating for a particular title is required, there’s a function to get the data:

import db from '../db/models';

class TitleRatings {
  constructor() {}

  public async getRatingsByUser(imdbId, userId) {
    const ratings = await db.user_titles.findOne({ where: { imdbId, userId } });

    if (!ratings) {
      return null;
    }

    return {
      rating: ratings.watchedRating,
    };
  }
}

This rating is required when the user first opens a title, among other areas, because the user may have rated the title previously and that should be shown in the UI.

Other user title data points

As well as taking a user rating for a title, the app also has functionality for users to add a title to their watch list, and also say wether they like the look of a film. These two data points require the same values for storage as the title rating, which is the user ID, the IMDB ID, and either isOnList (true or false) and likeLookOf (true or false). Because the data points are so similar, everything is stored in the same table. With 2 levels of caching and heavily indexed tables, this is a great way to utilise a single table for multiple uses.


Senior Engineer at Haven

© Jay Gould 2023, Built with love and tequila.