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:
- Part 1: Deploy a Leaderboard Service
- Part 2: Build a React Frontend for the Leaderboard API (this post)
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:
Resource | Method | Description |
---|---|---|
/leaderboards | GET | Retrieves the names and IDs of all leaderboards |
/leaderboards | POST | Creates a new leaderboard |
/leaderboards/{id} | GET | Retrieves a leaderboard with a given ID |
/leaderboards/{id}/scores | POST | Adds 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.
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 </th>
<th>Name </th>
<th>Score </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.
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!