Skip to main content
Lachlan Heywood
Lachlan Heywood
Lachlan Heywood
||10 min read

Build with Cosmonic - Part 2: Building a React Frontend for the Leaderboard API

In our last post we built an deployed a service with Rust and deployed it to Cosmonic's free infrastructure. In this post we'll build a React frontend to interact with the service.

Series

This blog post is part of a series on building applications on Cosmonic. If you haven't read the previous posts, you may want to start there:

Introduction

In this post, we'll focus most of our energy on interacting with a Cosmonic-hosted leaderboard service. If you're interested in learning more about React in general, we recommend checking out the newly updated React docs. Their Tic-tac-toe tutorial is also a great place to start. We've already started a React application that you can clone from GitHub, or you can start from the CodeSandbox template.

Overview of the React Application

To start us off, some of the components have already been written for you. Or, if I were hosting a quality morning TV cooking show, "here's some I prepared earlier". The first is the Game component which is a simple click-as-fast-as-you-can game. There's some pretty basic logic in there: start a timer, keep track of the score, and to stop when the game is over. Once you've clicked your heart out, the Game component will call a function called onEnd with the final score. There is also an App component which keeps track of the current view, and a NavBar component allows you to switch between the game and the leaderboard.

The components we will be looking at are the Team, Leaderboard and NewScore components. The Team component is a compositional component that will ask for a team name and save it to the browser's local storage before revealing the rest of the children components. The Leaderboard component will fetch the leaderboard from the API based on the team name and display the scores. Lastly, the NewScore component will display a form for the user to enter their name, and then send the score to the API.

The Leaderboard API

As a recap, here is the API we built in part one:

ResourceMethodDescription
/leaderboardsGETRetrieves the names and IDs of all leaderboards
/leaderboardsPOSTCreates a new leaderboard
/leaderboards/{id}GETRetrieves a leaderboard with a given ID
/leaderboards/{id}/scoresPOSTAdds a score to the leaderboard. Note that the score could "fall off" the bottom if low enough

Building the React Application

Let's get started. As mentioned, you can find the finished project on GitHub. This is what the final product will look like:

Variables

Inside the environment.ts file, you will need to change the API_URL to the one that is deployed from your Cosmonic account. You can find this by clicking on the wormhole and clicking the "Access your wormhole" link.

Screenshot showing Wormhole name

Team Component

The Team component is in its starting point here. You'll notice that the onSubmit handler is not yet implemented. Let's fix that.

const Team = ({children}: PropsWithChildren) => {
  const savedTeamName = window.localStorage.getItem(TEAM_NAME_KEY);
  const [team, setTeam] = useState<string>('');

  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    // TODO: implement
  };

  return (
    <div className={styles.Team}>
      {!savedTeamName && (
        <form onSubmit={handleSubmit}>
          <label htmlFor="team">Team: </label>
          <input
            aria-label="Team name"
            className={styles.input}
            type="text"
            id="team"
            required
            value={team}
            onChange={(event) => setTeam(event.target.value)}
            pattern="[a-zA-Z0-9_-]{1,26}"
            title="alphanumeric characters with hyphen and underscore only"
          />
          <button type="submit" className={styles.button}>
            Save
          </button>
        </form>
      )}
      {savedTeamName && children}
    </div>
  );
};

There are two things that need to happen here. First, we need to save the team name to the browser's local storage. Secondly, we need to make sure the name is sent to the API. Let's start with the first one.

Inside the handleSubmit function, use the TEAM_NAME_KEY constant to save the team name to the browser's local storage.

window.localStorage.setItem(TEAM_NAME_KEY, team);

Next, we need to make sure the team name is sent to the API. To do this, we'll be using the fetch API. We don't actually care what the response is because the way we set up the API, it will create a leaderboard if none exists, or return the existing leaderboard if it does. We'll also add a catch block to alert the user if something goes wrong.

fetch(`${API_URL}/leaderboards`, {
  method: 'POST',
  body: JSON.stringify({id: team, name: team}),
})
  .then(() => {
    window.localStorage.setItem(TEAM_NAME_KEY, team);
  })
  .catch((error) => {
    alert(error);
  });

Leaderboard Component

Here is the starting point for the Leaderboard component. This component will be responsible for fetching the leaderboard from the API and displaying it. We will want a loading state and an empty state for when there are no scores for a given leaderboard.

interface Score {
  owner_id: string;
  owner_name: string;
  value: number;
}

const Leaderboard = () => {
  const [scores, setScores] = useState<Score[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const teamName = window.localStorage.getItem(TEAM_NAME_KEY);

  // TODO: implement

  return (
    <table className={styles.table}>
      <thead>
        <tr>
          <th>Rank&emsp;</th>
          <th>Name&emsp;</th>
          <th>Score&emsp;</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>#1</td>
          <td>John</td>
          <td>100</td>
        </tr>
      </tbody>
    </table>
  );
};

First, let's implement the loading state. We're going to use a piece of state called loading to keep track of whether or not the scores are loading. We'll set it to true when we start fetching the scores, and false when we're done. We'll also use the useEffect hook to fetch the scores when the component mounts. We'll also want to make sure we only fetch the scores when the team name changes, so we'll add that as a dependency to the useEffect hook.

useEffect(() => {
  setLoading(true);
  fetch(`${API_URL}/leaderboards/${teamName}`)
    .then((res) => res.json())
    .then((data) => {
      setLoading(false);
      setScores(data.scores);
    });
}, [teamName]);

Inside the return statement, we'll want to render the loading state if the scores are loading. We can do this by checking the loading state and rendering a loading message if it is true. Do this just inside the tbody tag.

{
  loading && (
    <tr>
      <td colSpan={3}>Loading...</td>
    </tr>
  );
}

Next, we'll want to render the empty state if there are no scores. We can do this by checking the length of the scores array and rendering a message if it is empty. Do this after the loading state.

{
  !loading && scores.length === 0 && (
    <tr>
      <td colSpan={3}>No scores yet</td>
    </tr>
  );
}

Finally, we'll want to render the scores if there are any. We can do this by mapping over the scores array and rendering a row for each score. Do this after the empty state. Since the scores are empty when the component mounts, we don't need to wrap this with any conditions since react will just render nothing if the array is empty.

{
  scores.map((score, index) => {
    return (
      <tr key={index}>
        <td>{index + 1}</td>
        <td>{score.owner_name}</td>
        <td>{score.value}</td>
      </tr>
    );
  });
}

We can't really test this component yet because we don't have any scores in the API. We'll add some scores in the next section.

NewScore Component

Here is the starting point for the NewScore component. This component will be responsible for submitting new scores to the API. The App component already has a handleNewScore function that will be passed to this component as a prop. This component will be displayed when the user completes a game.

const NewScore = ({onSubmit, score}: Props) => {
  const player = window.localStorage.getItem(PLAYER_NAME_KEY) || '';
  const team = window.localStorage.getItem(TEAM_NAME_KEY) || '';
  const [name, setName] = useState<string>(player);
  const [loading, setLoading] = useState<boolean>(false);

  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    // TODO: implement
  };

  return (
    <div className={style.NewScore}>
      <form onSubmit={handleSubmit} className={style.form}>
        <div className={style.message}>NEW HIGH SCORE!</div>

        <div className={style.fields}>
          <label htmlFor="name">Name</label>
          <input
            type="text"
            id="name"
            required
            value={name}
            onChange={(event) => setName(event.target.value)}
            pattern="[a-zA-Z0-9_-]{1,26}"
            title="alphanumeric characters with hyphen and underscore only"
          />

          <label htmlFor="score">Score</label>
          <input type="text" id="score" value={score} readOnly />

          <button type="submit">Submit</button>
        </div>
      </form>
    </div>
  );
};

We need to know if the score is a high score, so that we can display the form to submit the score if it is, or a message if it isn't. Let's start with that first. We can do this by checking the last value that comes back from the API. Let's add a loading state and show a loading message while we fetch the scores. This is the same as what we did in the Leaderboard component.

// before the return statement
useEffect(() => {
  fetch(`${API_URL}/leaderboards/${team}`)
    .then((res) => res.json())
    .then((data) => {
      const highScores = data.scores;
      setLoading(false);
    });
}, [team, score]);
// inside the first `<div>` in the return statement
{loading ? (
  <span>Loading...</span>
) : (
  // ...existing form
)}

Once the scores are loaded, we can check if the score is a high score. The last score in the array will be the lowest high score, so we can check if the score is greater than or equal to that. If there are less than 10 scores, then the score is a high score. We can use the setLowestHighScore function to set the lowest high score in the component state using the useState hook. We'll also want to set the lowest high score to 0 if there are less than 10 scores.

// at the top of the component
const [lowestHighScore, setLowestHighScore] = useState<number>(0);

// inside the fetch callback hook, after the response comes back
if (highScores.length < 10) {
  setLowestHighScore(0);
} else {
  setLowestHighScore(highScores[9].value);
}

Next, we'll want to render the form if the score is a high score. We can do this by checking the lowestHighScore state and rendering the form if the score is greater than or equal to it. Since there are two elements that we want to render, we'll need to wrap them in a fragment.

<>
  {score <= lowestHighScore ? (
    <div>
      <div className={style.message}>Sorry, not a high score</div>
      <div>
        <button onClick={() => onSubmit(name, score)}>
          See Scores
        </button>
      </div>
    </div>
  ) : (
    // ...existing form
  )}
</>

That's it! You've successfully built a React application that can connect to a Cosmonic hosted service. You can test this out by running the application locally and playing a game. See if you can beat your high score!

If you'd like to see how the final application looks, you can check out the following CodeSandbox.

Edit Leaderboard UI

Wrap-up

In this post, we've covered how to connect a React application to a Cosmonic hosted service. If you found this sample interesting, we encourage you to tinker with it and share your experiences with us in our Discord. Keep an eye out for the next post in this series!