DEV Community

Cover image for Comic Book App With Marvel API and React
Aleks Popovic
Aleks Popovic

Posted on • Edited on • Originally published at aleksandarpopovic.com

Comic Book App With Marvel API and React

I grew up on both DC and Marvel comic books, cartoons and TV shows. From the 1966 Batman series, to 1967 Spider man cartoons, and 1940s Superman cartoons, which were super old even in the 90s, but I guess that's what our TV companies could afford at the time.

Nowadays if I want to read a comic book I have no idea where to even begin. There are so many different characters, storylines and multiverses which may or may not be connected. Which got me thinking - what if I had a searchable comic book library where I could enter a character’s name and I would get all of their comic books from which I can pick and choose what to read?

The original idea was to make it searchable by both Marvel and DC characters but DC doesn't seem to have an official public API while Marvel does, so that will have to do for this project. For more general superhero information you can also use superheroAPI, but to get the comic book data we will use Marvel's official API. Before starting you should make sure you have set up your Marvel developer account and you have access to your private and public API keys, as we will need those later.

If you prefer a video version of this tutorial you can watch me build the comic book library app in React on Youtube:

I started a new React app through Vite by running:

yarn create vite
Enter fullscreen mode Exit fullscreen mode

If you don't use yarn you can find detailed scaffolding instructions on Vite's Getting Started page.

For this project we are going to use two additional packages - sass for writing Sass, and md5 for hashing one of the parameters for our API request. To install them run:

yarn add sass md5
Enter fullscreen mode Exit fullscreen mode

We are also going to use a couple of images in our styling which you can grab from my GitHub repo. Make sure to put them in your /src/images folder.

Final peace of setup that you need to do before continuing is configuring your environment variables. If you are working with Vite like me you need to create a new file called .env.local in your project's root and add these two properties to it.

VITE_PUBLIC_KEY = "YOUR_PUBLIC_KEY"
VITE_PRIVATE_KEY = "YOUR_PRIVATE_KEY"
Enter fullscreen mode Exit fullscreen mode

Make sure to replace the strings with your actual public and private keys from the Marvel's developer portal. Your variables need to be prefixed with VITE_, so make sure to not remove that part. After that you can proceed with creating components.

To start off I made a new folder called components and in there I added a new component called Search.jsx and imported it into App.jsx. Here is what the App.jsx looks like.

// App.js

import "./App.css"
import Search from "./components/Search"

function App() {
  return (
    <div className="App">
      <Search />
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

The main purpose of the app is to be able to search comic book characters by their name. Once we select a character we should get a list of their comic books and clicking on a comic book should show us more details about that specific comic book.

Most of our app's logic is going to happen in the Search component, so here is what it should look like.

// Search.jsx

import "../styles/Search.scss"
import { useState } from "react"
import md5 from "md5"
import Characters from "./Characters"
import Comics from "./Comics"

export default function Search() {
  const [characterName, setCharacterName] = useState("")
  const [characterData, setCharacterData] = useState(null)
  const [comicData, setComicData] = useState(null)

  const publicKey = import.meta.env.VITE_PUBLIC_KEY
  const privateKey = import.meta.env.VITE_PRIVATE_KEY

  const handleSubmit = (event) => {
    event.preventDefault()
    getCharacterData()
  }

  const getCharacterData = () => {
    setCharacterData(null)
    setComicData(null)

    const timeStamp = new Date().getTime()
    const hash = generateHash(timeStamp)

    const url = `https://gateway.marvel.com:443/v1/public/characters?apikey=${publicKey}&hash=${hash}&ts=${timeStamp}&nameStartsWith=${characterName}&limit=100`

    fetch(url)
      .then(response => response.json())
      .then(result => {
        setCharacterData(result.data)
        console.log(result.data)
      })
      .catch(error => {
        console.log("There was an error:", error)
      })
  }

  const getComicData = (characterId) => {
    window.scrollTo({ top: 0, left: 0 })

    const timeStamp = new Date().getTime()
    const hash = generateHash(timeStamp)

    const url = `https://gateway.marvel.com:443/v1/public/characters/${characterId}/comics?apikey=${publicKey}&hash=${hash}&ts=${timeStamp}`

    fetch(url)
      .then(response => response.json())
      .then(results => {
        setComicData(results.data)
        console.log(results)
      })
      .catch(error => {
        console.log("Error while fetching comic data", error)
      })
  }

  const generateHash = (timeStamp) => {
    return md5(timeStamp + privateKey + publicKey)
  }

  const handleChange = (event) => {
    setCharacterName(event.target.value)
  }

  const handleReset = () => {
    setCharacterData(null)
    setComicData(null)
    setCharacterName("")
  }

  return (
    <>
      <form className="search" onSubmit={handleSubmit}>
        <input
          placeholder="ENTER CHARACTER NAME"
          type="text"
          onChange={handleChange}
        />
        <div className="buttons">
          <button type="submit">Get character data</button>
          <button type="reset" className="reset" onClick={handleReset}>
            Reset
          </button>
        </div>
      </form>

      {!comicData && characterData && characterData.results[0] && (
        <Characters data={characterData.results} onClick={getComicData} />
      )}

      {comicData && comicData.results[0] && <Comics data={comicData.results} />}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

The Search component might look a bit daunting but in essence it's not too complicated. We have a submittable form with an input text field and a few buttons. The input has an onChange event that is calling the handleChange function which is a very simple function in which we set the character's name through useState.

Submitting the form or clicking the submit button will call the handleSubmit function which calls the getCharacterData function. In getCharacterData we do several things. We reset our state and make a hashed timestamp which we need as one of the API parameters. We are doing this by combining the timestamp with our private and public API keys that we got from the Marvel's developer portal and passing them into md5 function which we are importing from the md5 package.

If you followed my .env file setup from the beginning you can use your environment variables with import.meta.env.VITE_PUBLIC_KEY and import.meta.env.VITE_PRIVATE_KEY.

We are conditionally showing Characters and Comics component at the bottom. The idea here is that we want to show Characters if we have character data. If we have the comic book data then we want to hide Characters and show only Comics.

We are passing in getComicData function into Characters component which is going to be used as an onClick event in there. The function is very similar to getCharacterData, but we are calling a different endpoint and this time we need the characterId as an additional parameter, because we are fetching only that character's comic books.

Finally, handleReset is used to reset all component state, as its name implies.

Here is what the Characters component looks like.

// Characters.jsx

import "../styles/Characters.scss"

export default function Characters({ data, onClick }) {
  return (
    <div className="characters">
      {data.map(character => {
        return (
          <div
            key={character.id}
            className="characterCard"
            style={{
              background: `url(${character.thumbnail.path}.${character.thumbnail.extension}) no-repeat center`,
              backgroundSize: "cover",
            }}
            onClick={() => onClick(character.id)}
          >
            <div className="caption">{character.name}</div>
            <div className="caption bottom">View Comics</div>
          </div>
        )
      })}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

We are passing our character data and our onClick event in there and we are simply mapping everything into character card elements. We are dynamically setting the card background by using an image URL we get from character data.

We are using two different captions at the bottom. The first one will be visible by default and it displays the character's name. The second one will be hidden and on mouse hover the character's name will dissapear and the View Comics caption will pop out. We are using this as a way to indicate the cards are clickable. So far both captions are being shown, but we will fix this later in CSS.

Here is what the Comics component looks like.

// Comics.jsx

import "../styles/Comics.scss"

export default function Comics({ data }) {
  return (
    <div className="comics">
      {data.map(comic => {
        const detailsUrl = comic.urls.find(
          element => element["type"] === "detail"
        ).url

        return (
          <a
            key={comic.id}
            className="comicCard"
            style={{
              background: `url(${comic.thumbnail.path}.${comic.thumbnail.extension}) no-repeat center`,
              backgroundSize: "cover",
            }}
            href={detailsUrl}
            target="_blank"
            rel="noreferrer"
          >
            <div className="caption">{comic.title}</div>
            <div className="caption bottom">View Comic Details</div>
          </a>
        )
      })}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

This one is similar to Characters component with a few key differences. Before mapping out the comic book cards we need to find the URL for each comic on Marvel's official website. Each comic has a property called urls which contains different objects which have two properties - type and url. The object which has its type equal to detail contains our URL in its url property, so we need to find it and extract it.

We are then free to map out all comic book elements into comic card links. We are setting their background the same way we did for the Comics component and we are also adding two captions, but this time the default one shows the comic book title.

That is everything as far as our functionalities go. If you want to set up your CSS the same way as I did you can use the following code. Also, make sure to put your .scss files into a separate styles folder.

// Search.scss

.search {
  display: flex;
  flex-direction: column;
  align-items: center;
  max-width: 500px;
  margin: 1em auto;
}

input {
  width: 300px;
  font-size: 1.4em;
  text-align: center;
  margin: 1em 0;
  font-family: "Bangers", cursive;
  padding: 0.5em 0;
}

button {
  font-size: 1.2em;
  padding: 0.5em 1em;
  margin-bottom: 1em;
  cursor: pointer;
  font-family: "Bangers", cursive;
  background: rgb(255, 240, 33);
  transition: linear 0.2s;
  border: 1px solid black;
  box-shadow: 2px 2px black;

  &:hover {
    transform: translate(-2px, -2px);
    box-shadow: 4px 4px black;
  }

  &.reset {
    margin-left: 0.25em;
    background-color: white;
  }
}
Enter fullscreen mode Exit fullscreen mode
// Characters.scss

.characters {
  max-width: 80vw;
  margin: 0 auto;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-gap: 10px;
  padding: 1em;
  background-color: white;
  background-image: url(../images/paper.jpg);
}

.characterCard {
  padding: 1em;
  display: flex;
  flex-direction: column;
  height: 300px;
  border: 2px solid black;
  box-shadow: 4px 4px black;
  filter: grayscale(100);

  transition: 0.2s linear;

  .caption {
    font-family: "Bangers", cursive;
    font-size: 1.6em;
    text-align: center;
    margin: auto auto 0 auto;
    padding: 0.5em 1em;

    background-color: white;
    background-image: url(../images/paper.jpg);

    border: 1px solid black;
    box-shadow: 2px 2px black;

    &.bottom {
      position: absolute;
      bottom: 1rem;
      left: 50%;
      transform: translateX(-50%);
      opacity: 0;
    }
  }

  &:hover {
    cursor: pointer;
    filter: grayscale(0);
    box-shadow: 6px 6px black;
    transform: translate(-2px, -2px);

    .caption {
      opacity: 0;
    }

    .bottom {
      opacity: 1;
    }
  }
}

@media only screen and (max-width: 800px) {
  .characters {
    grid-template-columns: repeat(1, 1fr);
  }
}
Enter fullscreen mode Exit fullscreen mode
// Comics.scss

.comics {
  max-width: 80vw;
  margin: 0 auto;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  grid-gap: 10px;
  padding: 1em;
  background-color: white;
  background-image: url(../images/paper.jpg);
}

.comicCard {
  padding: 1em;
  display: flex;
  flex-direction: column;
  height: 400px;
  border: 2px solid black;
  box-shadow: 4px 4px black;
  filter: grayscale(100);

  transition: 0.2s linear;
  text-decoration-color: black;

  .caption {
    font-family: "Bangers", cursive;
    font-size: 1.6em;
    text-align: center;
    margin: auto auto 0 auto;
    padding: 0.5em 1em;

    background-color: white;
    background-image: url(../images/paper.jpg);

    color: black;
    border: 1px solid black;
    box-shadow: 2px 2px black;

    &.bottom {
      position: absolute;
      bottom: 1rem;
      left: 50%;
      transform: translateX(-50%);
      opacity: 0;
      color: black;
      text-decoration: underline;
      width: 60%;
    }
  }

  &:hover {
    cursor: pointer;
    filter: grayscale(0);
    box-shadow: 6px 6px black;
    transform: translate(-2px, -2px);

    .caption {
      opacity: 0;
    }

    .bottom {
      opacity: 1;
    }
  }
}

@media only screen and (max-width: 1200px) {
  .comics {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media only screen and (max-width: 800px) {
  .comics {
    grid-template-columns: repeat(1, 1fr);
  }
}
Enter fullscreen mode Exit fullscreen mode

You will also need to add a small chunk of CSS in your index.css file at the root of your project, to make sure your background and fonts are being properly displayed. Here is what my index.css looks like.

@import url("https://fonts.googleapis.com/css2?family=Bangers&display=swap");
body {
  margin: 0;
  background: linear-gradient(
        45deg,
        rgba(0, 110, 193, 0.3),
        rgba(0, 115, 138, 0.1)
      ) no-repeat fixed center, url("./images/spiderman.jpg") no-repeat fixed
      center;
  background-size: cover;
}
Enter fullscreen mode Exit fullscreen mode

And with that our Marvel Comic Book app is finished. You can of course expand it with other interesting functionalities, such as bookmarking your favorite characters and comics that you want to read, or making a database of comics you already finished reading. You can also expand the search functionality to also include and directly search comic book names, or events that are connected to the Marvel universe (you can find these properties and endpoints in the API documentation). If you do end up improving this app send me a message. I would love to see your creations!


If you have any questions or comments you can reach out to me on Twitter and Instagram, where I also post interesting code tidbits and designs.

I also have a YouTube channel where I regularly upload React and web dev tutorials, so if that's your cup of tea feel free to support me by subscribing.

Top comments (3)

Collapse
 
ekeijl profile image
Edwin • Edited

I scanned through your article and (maybe I missed something, but) I noticed that you expose the private key for your API to the client. This is really bad advice (even for a hobby project) and will expose your API account to anyone who likes to use it. Novice developers who read this article will copy your code and think it is standard practise, so I think you should make some adjustments.

As stated in the Vite docs, any env var prefixed by VITE_ will be string-replaced in the code, so they private key is in your client bundle.

To prevent accidentally leaking env variables to the client, only variables prefixed with VITE_ are exposed to your Vite-processed code

What you should do is set up a very simple API server with Express or something similar. The client makes requests to your Express server. The Express server calls the Marvel API with the private key and forwards the results to the client.


Other than that, I wouldn't recommend using SASS anymore and you're not really using any SASS features anyway. Nowadays, native CSS can do almost everything SASS can do, even nested selectors.

Collapse
 
alekswritescode profile image
Aleks Popovic

Thank you for the feedback Edwin. For a learning project, and almost all of my tutorials are learning projects, it's a bit of an overkill to set up a server just for the API key. I usually mention that setting up API calls on the frontend isn't the best practice, but I haven't written/recorded tutorials for the past 2-3 years, so I missed mentioning it in this one as well. Also, my tutorials are geared towards people starting with React/frontend, so I stayed away from introducing backend stuff into the mix, but maybe I should change that.

As for Sass, I do mention it's a preference thing in the video. I've been using it daily for the past 6-7 years and a lot of companies have it as a job requirement, so being at least familiar with it is a plus for anyone. Your article on modern CSS features is great, but we'll have to wait and see if companies are just going to ditch Sass or pre-processors entirely.

The visual part is mostly irrelevant here as it's not a CSS tutorial. A lot of the times I just point readers/viewers to copy and paste my CSS files if they aren't interested in that part, and I either skim through the code, or skip it entirely.

Thank you for commenting, I appreciate it.

Collapse
 
alekswritescode profile image
Aleks Popovic

To be fair, I haven't been active anywhere, let alone Twitter or Instagram, so if you have any questions or suggestions feel free to write them here. :)