Learn React.js by Building a Game Search App | Part 2/3 - Building The Search Bar and Fetching Data From The Backend
Since we have mostly finished our setup in the previous part of this tutorial (You can read part 1 here). We’re ready to start working on the search page of the app which is the main page of the application.
In that page, the user should be able to use the search bar to search for a given game title or franchise and the app should call our backend which will call the RAWG API to retrieve a list of relevant games. Then, the app should display those games as cards containing a cover, a title, genres and how long it takes to beat the game if that data is available.
Now that you know what we need to build, let’s get started. I’ll introduce relevant concepts as we go.
Table of Contents
How is React Initialized in Our Project?
If you look at your src
folder, you should have the following files.
src
|- assets
|- App.css
|- App.jsx
|- index.css
|- main.jsx
Before we write any code, let’s remove the assets
folder and the App.css
file. We don’t need these two. Since we’re using Tailwind, the only place we need for CSS is the index.css
file.
Now let’s take a look at main.jsx
.
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
For React to be used in a webpage, it needs to inject the content of your app within your HTML. To be more precise, we first need to create a root within your index.html
file from which your React app will be rendered.
If you take a look at your index.html
file at the root of your project’s folder. You will notice the presence of a div
with an id of “root”. This was added automatically by Vite.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Looking back at our React code :
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
We can see that to inject our React app within the div
having the id “root” we pass that div
as a param of the createRoot
function. It then creates an object that has a method called render
. We immediately call it to pass our main React component called App
wrapped under StrictMode (which adds useful behaviors to more easily find bugs).
What are React Components?
The component is the main building block when working in React. It allows you to build UIs in a modular fashion allowing you to reuse components in multiple places. It also allows you to manage complexity by placing logic specific to a component within it.
At a basic level, a React component is just a JavaScript function that returns some JSX. Which, if you remember, is syntax that looks like HTML that you can write within JavaScript in a .jsx file.
Here is an example!
function App(){
return (
<div>
<h1>Hello World!</h1>
</div>
);
}
We have a React component that returns a div
containing an h1
tag with the content “Hello World!”. You will surely notice that we defined this component by defining a function with its name’s first letter capitalized. The convention when defining React components is to use the PascalCase notation rather than the usual camelCase.
Another thing that makes components really useful in React is that you can place React components within React components, enabling a modular approach to building UIs. Here’s another example.
function Card() {
return <section>This is a card</section>
}
function App() {
return (
<div>
<h1>Results</h1>
<div>
<Card />
</div>
</div>
)
}
As you can see, to place a component within another you can use the self closing tag notation you’re used to from HTML along with the name of that component.
React VS React-DOM
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
Looking back at the import statements we had in main.jsx
. You’ll notice that we’re importing things from both react
and react-dom
. You might be wondering why they are separate entities?
React is the library that allows us to build UIs using reusable blocks called components that return JSX. React-DOM on the other hand is the one taking care of rendering React components on the DOM (Document Object Model) of the webpage.
That’s why createRoot
is imported from react-dom
and not react
since it’s used to render to the DOM with the render
method. The reason for this split is that React can be used outside of the web. For example, it can be used for mobile development with React Native. Instead of the JSX being translated to HTML elements that are rendered on a webpage, they’re are translated to native mobile UI elements instead.
In addition to React being used for mobile UIs, it can also be used to make pdfs with react-pdf.
Making Our First React Component
Now that you understand how components work, let’s define our first component, the App
component.
If you take a look at main.jsx
again, you’ll notice that we’re already importing this component from a file called App.jsx
.
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx' // <--
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
Let’s start from scratch by deleting everything in the existing App.jsx
file
and adding the following code which will display the app’s name and logo.
export default function App() {
return (
<main className="w-full p-2 flex flex-col items-center">
<div className="flex items-center">
<h1>Game Database</h1>
<img src="./logo.png" className="w-25 h-25" />
</div>
</main>
);
}
The main
tag will contain all of the content of the search page. We define it as a flex
container with a direction set to column
. We then center the content within that main
tag along the cross axis of the container using items-center
. Below is a schematic displaying how the axis are defined according to the direction of the flex container. Remember that using items-center
will center the content of the flex container along the cross axis while justify-center
will center its content along the main axis.
We then make the main
tag take the full available width of the page using w-full
and give it a padding of 2 with p-2
. All of the styling explained above is succinctly described in Tailwind by passing the needed class names to the className property of the JSX element.
<main className="w-full p-2 flex flex-col items-center">
Now, you might be wondering why is it className
instead of class
that you’re accustomed to in HTML? The reason is that there is a difference in how the attribute is named in HTML vs the DOM which the underlying model representing a webpage. Since React operates on the DOM it was chosen to use className
instead class
which is what the DOM uses.
Now within the main
tag, we define a div
which will hold the app’s name and logo.
<div className="flex items-center">
<h1>Game Database</h1>
<img src="./logo.png" className="w-25 h-25" />
</div>
This div
is set as a flex container with the direction set to row
by default, so we don’t need to specify it. We center its content along the cross axis so that the app’s title and logo are near each other at the center.
We then use an img
tag to display the logo. You might have noticed that the path to the image does not contain the public
folder despite the image being in that folder. That’s because Vite will automatically make whatever is in the public
folder as if it was available from the root of the project, so you do not need to specify the public
folder in the path.
Finally w-25
and h-25
sets the size of the logo using units defined by Tailwind which are responsive.
If you now start a development server using the command npm run dev
in your terminal, you should get the following result.
Let’s makes things prettier before moving on. In index.html
add the following Tailwind classes to the body tag.
<body class="bg-gray-950 bg-[url('./background.png')] bg-repeat">
We’re setting the background color to be of gray-950 which is a color predefined by Tailwind that is near black. We then set the background image and set it to repeat so that it tiles indefinitely making the image cover the page regardless of the size of the viewport.
You should have the following result.
As you can see the app’s name near the logo is not visible. To fix the issue, let’s add a nice looking gradient to it. To showcase how you can create custom classes composed of Tailwind classes, let’s add the following in our index.css
file.
.text-gradient {
@apply bg-linear-to-r from-[#5c2bd9] via-[#fb6e6e] to-[#ffe137] bg-clip-text text-transparent;
}
The key here is to use the @apply
directive and then use the Tailwind classes you need.
bg-linear-to-r from-[#5c2bd9] via-[#fb6e6e] to-[#ffe137]
Creates the gradient.
bg-clip-text text-transparent
Makes sure the gradient is applied within the text’s letters.
If you go back to our React component and apply the custom Tailwind class we created along with a few other Tailwind classes, you should get the following result.
<h1 className="text-gradient text-4xl font-bold">Game Database</h1>
Creating The Search Component
In the previous section we created our main React component acting as the entrypoint of our UI. However, we need to define more than just one component in our app. To keep things organized, let’s create a components
folder under the src
folder. Within, let’s define our next component, the Search
component by creating a Search.jsx
file.
src
|- components
| |- Search.jsx
|- App.jsx
|- main.jsx
|- index.css
Add the following code in Search.jsx
.
export default function Search({ query, onChange }) {
return (
<div className="w-full mb-4">
<div className="flex my-2 rounded-md shadow-xl bg-gray-800 p-4">
<img
className="relative top-0.5 h-5 w-5"
src="./search.svg"
alt="search"
/>
<input
className="w-full focus:outline-none ml-2 text-gray-300"
type="text"
placeholder="Search a game"
value={query}
onChange={onChange}
/>
</div>
</div>
);
}
The Search
component is essentially a search bar. You’ll probably notice that we have params that are passed to this component. These are called props. It’s another one of React’s major concepts.
What Are Props in React?
What makes components flexible in React is the ability to pass props to them to change how they’re rendered. This makes components usable in more than just one screen or one place in the app.
You can view props the same way as params that you pass to a function.
Here is a basic example of how props are used in React.
function Title(props) {
return <h1>{props.name}</h1>;
}
function App() {
return (
<div>
<Title name="Hello World" />
</div>
);
}
To pass props to a component you can simply start adding arbitrary attributes to where you call this component. In the example above, I decided to use an attribute called “name” that is then available under the props object where the component is defined. That’s why I can do props.name
and get access to “Hello World”.
You might have noticed that we use curly braces when putting prop.name within the h1
tag. This is important, so that the actual value, “Hello World” is what’s rendered rather than having literally “prop.name” displayed.
A more convenient way of dealing with props is to use destructuring which is a JavaScript concept allowing us to directly get the properties we need.
function Title({name}) {
return <h1>{name}</h1>
}
So now, if I need more than one prop, I can do the following.
function Title({name, subtitle}){
return (
<div>
<h1>{name}</h1>
<h2>{subtitle}</h2>
</div>
);
}
function App() {
return (
<div>
<Title name="Hello World" subtitle="Hello World Again!" />
</div>
);
}
Now that you understand what props are and how they work, let’s further analyze the code for the Search
component.
export default function Search({ query, onChange }) {
return (
<div className="w-full mb-4">
<div className="flex my-2 rounded-md shadow-xl bg-gray-800 p-4">
<img
className="relative top-0.5 h-5 w-5"
src="./search.svg"
alt="search"
/>
<input
className="w-full focus:outline-none ml-2 text-gray-300"
type="text"
placeholder="Search a game"
value={query}
onChange={onChange}
/>
</div>
</div>
);
}
Most of the Tailwind classes here are easy to understand if you already know CSS so I will not go over each of them. I would like however, to explain a few that may not be as straightforward to figure out :
mb-4
: Applies 4 units to the bottom margin of the element. If you hadml
instead, it would be applied for margin left,mr
for margin right, etc…my-2
: Applies 2 units to both the top and bottom margin of the element. Therefore, along the y axis. If you where to usemx
, the left and right margins would be increased instead since they’re along the x axis.
Now let’s take a look at the input element.
export default function Search({ query, onChange }) {
return (
{/* previous code omitted for clarity */}
<input
className="w-full focus:outline-none ml-2 text-gray-300"
type="text"
placeholder="Search a game"
value={query} <--
onChange={onChange} <--
/>
{/* next code omitted for clarity */}
);
}
In React, the input attribute has two properties that are very useful.
The
value
attribute allowing us to set the value inside the field.The
onChange
attribute which takes a function and will run it every time the user types in the field. Which also allows us the get what was typed.
We pass to these two attributes the query
and onChange
props we defined for the Search
component. Now, go back to App.jsx
and add the following code.
import { useState } from "react";
import Search from "./components/Search";
export default function App() {
const [searchQuery, setSearchQuery] = useState("");
return (
<main className="w-full p-2 flex flex-col items-center">
{/* previous code omitted for clarity */}
<div className="w-full max-w-4xl">
<Search
query={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</main>
);
}
What is this useState
function we’re using? It’s called a hook and hooks are another one of React’s major concepts.
What Are Hooks in React?
Hooks let you use different React features from your components. You can either use the built-in hooks or combine them to build your own.
The useState Hook
const [searchQuery, setSearchQuery] = useState("");
Here in particular, we’re using a state hook to set a state variable with the goal of holding the user’s search query and to update the UI when it changes.
From React’s docs, the usefulness of the useState
hook is made apparent.
To update a component with new data, two things need to happen:
Retain the data between renders.
Trigger React to render the component with new data (re-rendering).
Creating a state variable using the useState hook allows us to achieve both.
A state variable to retain the data between renders.
A state setter function to update the variable and trigger React to render the component again.
In our case, searchQuery
is the state variable and setSearchQuery
is the state setter function. Finally, we pass within useState()
the default state value we want to use.
When creating a state variable here is the convention that is usually adopted.
const [nameOfTheVariable, setNameOfTheVariable] = useState(<default value. can be a number, null, a string, etc...>);
What makes React convenient for building UIs rather than using vanilla JS is that you don’t need to manual update the UI by changing the content of various HTML nodes. As we saw above, by using a state variable, React will take care of updating the UI automatically when the state variable changes.
<Search
query={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
By passing an onChange
function that calls the state setter function setSearchQuery
and passing to it the value of what was typed using e.target.value
, we update the searchQuery
state variable.
Since we’re also passing the searchQuery
state variable to the Search
component, React will re-render the component everytime it’s changed.
You should get the following result.
We could have defined the state variable within the Search
component but it’s better to do it in the App
component instead. This is a better approach because we need to have access to searchQuery
so that we can pass it to our backend and make the RAWG API call to get relevant search results.
Setting Our Backend to Enable Search Requests to The RAWG API
Now that the search bar is completed, let’s do some backend work to be able to query the RAWG API so it can provide the data we need.
Setting Up Express
In your backend repo, add the following code to your index.js
file.
import express from "express";
import cors from "cors";
import rateLimit from "express-rate-limit";
import fetch from "node-fetch";
import dotenv from "dotenv";
dotenv.config();
const API_KEY = process.env.API_KEY;
if (!API_KEY) {
throw new Error("API key not found in environment variables");
}
const app = express();
const corsOptions = {
origin: process.env.CLIENT_DOMAIN,
allowedHeaders: ["Content-Type", "Authorization"],
};
app.use(cors(corsOptions));
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: "Too many requests from this IP, please try again later.",
});
// Apply rate limiting to all API requests
app.use(limiter);
We first import all the needed packages. I explained why we needed each of them in part 1 of this tutorial. Refer to it here.
We then call dotenv.config()
to load our environment variables. We can then access them using process.env.<nameofthevariable>
We intialize the express web framework which is the core of our backend and pass it to a const called app
.
We then set our CORS (stands for Cross Origin Resource Sharing) options which allows us to only authorized requests coming from the domain of our frontend. One thing to know about CORS is that it’s only enforced through the web browser. That means, a mobile app could still make requests to our backend despite the options we set. Learn more about CORS here.
Using app.use(cors(corsOptions))
, we apply the CORS options to our Express app.
Finally we set up our rate limiter preventing potential DDoS attacks where a client makes too many requests in order to take down our backend.
To make our backend actually do something let’s add the following code.
app.get("/api/games", async (req, res) => {
// TODO
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
We define our first route that the client will be able to call and then make the Express app run by using app.listen
.
Defining The /api/games Route
We want this route to return a list of games that matches the query passed by the client.
In Express, a route is defined this way.
app.get("/api/games", async (req, res) => {
// TODO
});
Here we’re using a GET route since we’re not modifying a resource, we’re just requesting it. The first param is the route path we want to use, while the second param contains the function that’s going to run when the request is made. req.query
will contain the query of the client. Clientside, the query will be passed as a param to the route path. Here’s an example.
/api/games?search=Zelda
Will result in req.query
containing {search: “Zelda”}
.
Now let’s continue by filling out the logic for the route.
app.get("/api/games", async (req, res) => {
const { search: searchQuery } = req.query;
try {
const response = await fetch(
`https://api.rawg.io/api/games?key=${API_KEY}&search=${searchQuery}`,
{
method: "GET",
headers: {
//Authorization: `Bearer ${API_KEY}`, some APIs requires using the authorization header but not this one
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
return res
.status(500)
.json({ error: "Failed to fetch data from external API" });
}
const data = await response.json();
return res.json(data);
} catch (error) {
console.error(error);
return res
.status(500)
.json({ error: "Failed to fetch data from external API" });
}
});
Let’s break it down.
const { search: searchQuery } = req.query;
By using object destructuring we rename the search
param to searchQuery
. We do this because the RAWG API expect a search param and it would look weird to do :
`https://api.rawg.io/api/games?key=${API_KEY}&search=${search}`
However, this is just a matter of personal preference. You can decide to stick with search
if you want.
Within the Try/Catch statement we make the call to the RAWG API passing our API key from our environment variables.
const response = await fetch(
`https://api.rawg.io/api/games?key=${API_KEY}&search=${searchQuery}`,
{
method: "GET",
headers: {
//Authorization: `Bearer ${API_KEY}`, some APIs requires using the authorization header but not this one
"Content-Type": "application/json",
},
}
);
Something to note is that the RAWG API expects you to pass the API key as a param rather than putting it in the Authorization
header like other APIs. Also, in Node.js you can’t use fetch by default as you can on the frontend. That’s why we had to install the node-fetch package.
The rest of the code is pretty standard error handling. Either way, we always end up returning json with either an error message or the data the client requested.
Finally, remember to run the backend using the following command :
node index.js
Fetching Search Data on The Frontend
Now that we have created the backend route we needed, let’s head back to our frontend to write the logic allowing us to fetch the relevant data according to what query the user entered in the search bar.
We need to first talk about another very important hook in React called useEffect
.
What is The useEffect Hook and How it Works?
To put it simply, it’s a hook that lets you synchronize a component with an external system. In our case it’s going to be our backend.
In practice, it’s a function that’s called within a component and takes usually two params. The first is the function you want to run which is your “effect” and the other is a dependency array. It will run after the component is first rendered and every time a dependency in its dependency array changes.
import { useEffect } from "react";
export default function myComponent() {
useEffect(() => {
console.log("will run only once in prod and twice in dev");
}, []);
return <h1>Test</h1>;
}
Above is an example of how we use useEffect
. We have an empty dependency array meaning that the effect will run only after the component renders the first time. This occurs once in prod but twice in development because we wrapped our app with StrictMode
.
You can use useEffect
without a dependency array but it will run after every render.
useEffect(() => {
console.log("This runs after every render");
});
How is Data Fetched With useEffect?
import { useState, useEffect } from "react";
function myComponent() {
const [getNewQuote, setGetNewQuote] = useState(false);
const [currentQuote, setCurrentQuote] = useState("");
useEffect(() => {
// prevent calling api after the component first renders
if (!getNewQuote) return;
// async function needed within because the function passed to useEffect cannot be async.
const fetchData = async () => {
const response = await fetch("https://example.com/api/quote");
const data = await reponse.json()
setCurrentQuote(data.text);
setGetNewQuote(true);
}
fetchData();
}, [getNewQuote]);
return (
<div>
<h1>{currentQuote}</h1>
<button onClick={() => setSearch(true)}>Display New Quote</button>
</div>
);
}
In the example above, we fetch and display a new quote from a fictitious API when the user clicks on a button. You’ll notice that the dependency array of our useEffect
has the getNewQuote
state variable. This means that the hook will run once after the component is rendered the first time (twice in dev because of StrictMode) and then every time getNewQuote
is modified.
In practice, if you design your app in a way that you need the user to click a button to make an API call, you do not need to use useEffect
as you could put the fetching logic within a function you pass to the button’s onClick
handler. The example above was just to demonstrate at a basic level how this hook works.
In our app’s case, its usage is warranted since we need to make an API call when the user finishes typing something rather than clicking a button.
While using this hook like shown above is ok for toy examples, in practice when dealing with APIs, I would recommend using a library like Tanstack Query formerly known as React Query which makes fetching, caching, synchronizing and updating server state a breeze.
I recommend this part of the React docs regarding useEffect
. Oftentimes, you might be tempted to use it while it’s not necessary. The docs provide helpful guidance regarding this hook.
In our app, we will still use useEffect
but also create our own hook to deal with loading states (what to show the user while we’re fetching data) and error handling.
Creating a Custom Hook
While React offers hooks like useEffect
and useState
, nothing prevents you from creating your own hook. This is what we’re going to do now. Our hook is going to be called useFetch
and will be responsible for fetching the data we need from our backend and dealing with loading states and errors if they occur.
Create a new folder called hooks
within the src
folder and within a file called useFetch.js
.
src
|- components
|- hooks
|- useFetch.js
In the file you just created, add the following code :
import { useState } from "react";
export default function useFetch(fetchFunction) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchData = async () => {
try {
setLoading(true);
const result = await fetchFunction();
setData(result);
} catch (error) {
setError(error);
} finally {
setLoading(false);
}
};
const reset = () => {
setData(null);
setLoading(false);
setError(null);
};
return { data, loading, error, fetchData, reset };
}
useFetch
will expect a fetch function which will contain the API call and pass it to its own fetchData
function and set the appropriate loading or error states depending ont the situation. The hook returns the data, the loading state, the error state, the fetchData function to fetch data again if needed and finally, a reset function to reset the state if needed.
Let’s create the fetch function specific to getting the list of games related to a search query. For this purpose, let’s create file called api.js
within the src
folder. In it, add the following code.
src
|- components
|- hooks
|- api.js
const API_ENDPOINT = import.meta.env.VITE_API_ENDPOINT;
export async function fetchGames({ query }) {
const response = await fetch(`${API_ENDPOINT}/games?search=${query}`);
return await response.json();
}
For the code above to work we need to define an environment variable in our frontend. This is done just to make it easy to swap in and out the domain of the backend as needed and not for security reasons.
Create at the root of your project a .env
file containing the domain of our backend.
VITE_API_ENDPOINT="http://localhost:3000/api"
Now, we can go to App.jsx
where we will write the logic to fetch the needed data when the user enters a query in the search bar.
import { useEffect, useState } from "react";
import useFetch from "./hooks/useFetch";
import { fetchGames } from "./api";
export default function App() {
const [searchQuery, setSearchQuery] = useState("");
const { data, loading, fetchData, error, reset } = useFetch(() =>
fetchGames({ query: searchQuery })
);
useEffect(() => {
const timeoutId = setTimeout(async () => {
if (searchQuery.trim()) {
await fetchData();
return;
}
}, 500);
return () => clearTimeout(timeoutId);
}, [searchQuery]);
return (
// ... rest of the code omitted for clarity
Let’s break it down.
const [searchQuery, setSearchQuery] = useState("");
const { data, loading, fetchData, error, reset } = useFetch(() =>
fetchGames({ query: searchQuery })
);
We call useFetch
and pass to it an anonymous function that will call our fetchGames
function created in the api.js
file and pass to it the user’s search query. The reason we’re wrapping it in a function is that we don’t want to call fetchGames
immediately but rather let useFetch
call it in its fetchData
function. We also couldn’t pass it on its own because we wouldn’t be able to pass the search query param along.
Once called, the useFetch
hook will return all that we need to display the relevant UI depending on the situation.
useEffect(() => {
const timeoutId = setTimeout(async () => {
if (searchQuery.trim()) {
await fetchData();
}
}, 500);
return () => clearTimeout(timeoutId);
}, [searchQuery]);
Next, we have our useEffect
. We set a timeout of 500ms as to provide enough time after the user has typed before making a request otherwise we would be making a call every time a letter changes which would be wasteful. This is called debouncing.
Then, we have an if statement with a call to trim() that removes any whitespace at the beginning and end of a string. If the user didn’t type anything yet or just uses spaces in the search bar, an empty string will be returned which is falsy in JavaScript. Therefore, no API call will be made.
Since useEffect
will run after the component first renders, in that case the search bar would be empty, we wouldn’t want to make an API call after all.
The function we pass to useEffect
returns a function that clears the timeout we just created. This is called a cleanup function and is optional when using this hook but needed in our case. This is so we can avoid memory leaks. If we were to not use one here, every time the hook runs, we would create a new timer that would never be deleted.
That’s about it. The logic for fetching data is done. To make sure that it actually works, let’s console.log
the data fetched. Below the useEffect
we just added, create another one with the following content. (Yes, you can have more than one useEffect
per component.)
export default function App() {
useEffect(() => {
// code omitted ...
}, [searchQuery]);
useEffect(() => {
if (data) console.log(data)
}, [data]);
return (
//... code omitted
}
Make sure to re-run both the frontend with npm run dev
and the backend with node index.js
. Now, if you try using the app. You should get the following result.
Conclusion
In the last part of this tutorial, we will write the logic to display the search results in nice looking game card components and then work on displaying more details when the user clicks on a specific card.
UPDATE : Part 3 is now available!
Learn React.js by Building a Game Search App | Part 3/3 - Finishing The App
In the previous part (Here are links to part 1 and part 2), we finished setting up the logic to fetch data from our backend. Now it’s time to display the data in a nice looking UI.