Search code examples
javascriptreactjsreact-hooksreact-routerreact-router-dom

Using useLocation of React-Router-DOM to receive passed data in React results in manually inputted links not working


Initially, it works if the manually input link is wrong (Ex: "/characters/test"), but if it's the correct one, it still redirects to error 404. If the link is clicked from Character component, it works normally. Which means no matter what link I put manually on the browser, it results in error 404.

I've been trying to implement an error 404 redirect on a CharacterCard component by comparing the id being passed - 'name' (as useParams) and comparing it to an characterId list from another component being passed, if it matches, then it renders normally, if not, it redirects to error 404.

Since I'm using React-Router v6, I can't pass props normally as far as I know. After a bit of research, I found out I can use Link state to pass data to another component it links to and useLocation to get the passed data. I successfully passed data to the component, but then it results to the problem I stated above.

Codesandbox link for reproducibility: https://codesandbox.io/p/sandbox/funny-platform-xdlgkk

App.js (Route Declarations)

import { Route, Routes } from 'react-router-dom';
import './App.css';
import { Nations } from './components/Nations';
import { Navbar } from './components/Navbar';
import { Characters } from './components/Characters';
import { Home } from './components/Home';
import { Artifacts } from './components/Artifacts';
import { Weapons } from './components/Weapons';
import { CharacterCard } from './components/CharacterCard';
import { NotFound } from './components/NotFound';


function App() {

  return (
    <div className='bg-[#1e1f21]'>
      <Navbar />
      <Routes>
        <Route path='/' element={<Home/>}/>
        <Route path='/nations' element={<Nations/>}/>
        <Route path='/weapons' element={<Weapons/>}/>
        <Route path='/characters' element={<Characters/>}/>
        <Route path='/characters/:name' element={<CharacterCard/>}/>
        <Route path='/artifacts' element={<Artifacts/>}/>
        <Route path="*" element={<NotFound/>}/>
      </Routes>
      
    </div>
  );
}


export default App;

Characters

import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { PuffLoader } from "react-spinners";

const API_URL = 'something.somethin.com';
const CDN_URL = 'something2.somethin2.com';

export const Characters = () => {
  const [characters, setCharacters] = useState([]);
  const [isLoaded, setIsLoaded] = useState(false);

  const validCharacterIds = characters.map((character) => character.id);
  console.log('valid ids:', validCharacterIds);
  const getCharacters = async () => {
    try {
      const response = await fetch({API_URL});
      const charactersData = await response.json();

      const characterDetailsData = await Promise.all(
        charactersData.map(async (character) => {
          const characterDetails = await getCharacterDetails(character);
          return characterDetails;
        })
      );

      setCharacters(characterDetailsData);
      console.log('characters: ', characterDetailsData);
    } catch (error) {
      console.log("Error fetching Characters:", error);
    }
  };

  const getCharacterDetails = async (character) => {
    //code for getCharacterDetails

    setIsLoaded(true);
    return { ...characterDetailsData, id:character, icon: imageData };
  };

  useEffect(() => {
    getCharacters();
    document.title = "Characters | Website Title"
  }, []);

  return (
    <div className='w-full py-[3rem] px-4 bg-white'>
      //Character Card that when clicked opens CharacterCard Component
      <div className='w-[500px] md:w-[1200px] mx-auto grid grid-cols-1 md:grid-cols-5 gap-5'>
        {isLoaded ? (
          (characters.length > 0) ? (
            characters.map((character) => (
              <Link key={character.name} to={`/characters/${character.id}`} state={{charIds: validCharacterIds}} className='w-full shadow-xl flex flex-col my-4 rounded-lg hover:scale-105 duration-300'>
                <div className={
                  character.rarity === 4
                    ? "bg-gradient-to-b from-[#5e5789] to-[#9c75b7]"
                    : character.rarity === 5
                      ? "bg-gradient-to-b from-[#945c2c] to-[#b27330]"
                      : ''
                }>
                  <img className='w-[300px] h-[250px] object-cover' src={character.icon} alt={character.name + ' Icon'} />
                </div>
                <div>
                  <h4 className='text-2xl font-bold text-center py-8'>{character.name}</h4>
                </div>
              </Link>
            ))
          ) : (
            <div className='empty'>
              <h2>No Characters Found!</h2>
            </div>
          )
        ) : (
          <div className="flex justify-center items-center">
            <PuffLoader color="#36d7b7" />
          </div>
        )}
      </div>
    </div>
  );
};

CharacterCard

import React, { useEffect, useState } from 'react';
import { useNavigate, Navigate, useParams, useLocation } from 'react-router-dom';
import { PuffLoader } from 'react-spinners';
import Carousel from './Carousel';

const CDN_URL = 'somethin.somethin.com';
const API_URL = 'somethin2.somethin2.com';

export const CharacterCard = () => {
  const [characterDetails, setCharacterDetails] = useState(null);

  const navigate = useNavigate();
  const { name } = useParams();
  const location = useLocation();
  let validCharIds = location.state;
  console.log(location);

  const getCharacterDetails = async (characterName) => {
    //rest of the api fetch goes here
  };


  useEffect(() => {
    const checkValidCharId = async () => {
      if (!validCharIds || !validCharIds.charIds || !validCharIds.charIds.includes(name)) {
        // Redirect to error 404 if the name is not in the validCharIds array or empty
        navigate('*');
        console.log('error 404');
      } else {
        // Fetch character details only if the name is valid
        await getCharacterDetails(name);
      }
    };

    checkValidCharId();
  }, [name, validCharIds, navigate]);



  if(characterDetails) {
      return (
      //Render component normally

    );
  }

  else {
    return (
      <div className='flex justify-center items-center'>
         <Navigate to='*'/>
      </div>
    )
  }

};

Example Data: validCharacterIds validCharacterIds data for mapping

Characters Characters array after fetch


Solution

  • For what you describe, manually entering a character name in the URL path, the code is functioning correctly. This is because the CharacterCard component is expecting the user to have navigated to it's page via the "/characters" route such that all the characters data was fetched and the array of valid character names could be passed in route state when navigating from "/characters" to "/characters/:name". When you manually edit the address bar to navigate directly to "/characters/:name" the route state is null. This can only be generated via the Link and navigation action from one page to the other.

    Unfortunately another issue with the logic is even after a valid loading of all the character data and navigation to the character detail page, if the user refreshes/reloads the page, the characters state will be reset, including the array of valid ids, and the user will be bounced off the route.

    My desired outcome, that even if the user puts a valid/correct link manually for character it still loads correctly and not redirect to NotFound

    My suggestion here would be to create a layout route component that fetches and holds character data so that it can be loaded regardless of which "/characters/*" sub-route the user is on. Make "/characters/:name" wait to be mounted/rendered until after the characters data is fetched and available, then make the call for details only after it is determined the id is valid.

    Example:

    CharactersLayout.jsx - Move the character fetching and loading logic to the layout route component and provide the state out on the Outlet Context provider.

    import { useState, useEffect } from "react";
    import { Outlet } from "react-router-dom";
    import { PuffLoader } from "react-spinners";
    
    const API_URL = "https://genshin.jmp.blue";
    const CDN_URL = "https://cdn.wanderer.moe/genshin-impact";
    
    export const CharactersLayout = () => {
      const [characters, setCharacters] = useState([]);
      const [isLoading, setIsLoading] = useState(true);
    
      const getCharacters = async () => {
        setIsLoading(true);
        try {
          const response = await fetch(`${API_URL}/characters/`);
          const charactersData = await response.json();
    
          const characterDetailsData = await Promise.all(
            charactersData.map(getCharacterDetails)
          );
    
          setCharacters(characterDetailsData);
        } catch (error) {
        } finally {
          setIsLoading(false);
        }
      };
    
      const getCharacterDetails = async (character) => {
        const detailsResponse = await fetch(`${API_URL}/characters/${character}`);
        const characterDetailsData = await detailsResponse.json();
    
        const imageResponse = await fetch(
          `${CDN_URL}/character-icons/ui-avataricon-${character}.png`
        );
        let imageData;
    
        if (imageResponse.status === 404) {
          try {
            const altImageResponse = await fetch(
              `${API_URL}/characters/${character}/icon-big`
            );
            if (altImageResponse.ok) {
              imageData = await altImageResponse.url;
            } else {
              imageData =
                "https://mosmandentistry.com.au/wp-content/uploads/2016/10/orionthemes-placeholder-image-2.png";
            }
          } catch (error) {
            imageData =
              "https://mosmandentistry.com.au/wp-content/uploads/2016/10/orionthemes-placeholder-image-2.png";
          }
        } else {
          imageData = await imageResponse.url;
        }
    
        return { ...characterDetailsData, id: character, icon: imageData };
      };
    
      useEffect(() => {
        getCharacters();
      }, []);
    
      const validCharacterIds = characters.map(({ id }) => id);
    
      return isLoading ? (
        <div className="flex justify-center items-center">
          <p>Fetching characters</p>
          <PuffLoader color="#36d7b7" />
        </div>
      ) : (
        <Outlet
          context={{
            characters,
            isLoadingCharacters: isLoading,
            validCharacterIds
          }}
        />
      );
    };
    

    Character.jsx - Read characters array provided from Outlet Context. Don't pass any link state.

    import { useEffect } from "react";
    import { Link, useOutletContext } from "react-router-dom";
    
    export const Characters = () => {
      const { characters } = useOutletContext();
    
      useEffect(() => {
        document.title = "Characters | Kagerou.info";
      }, []);
    
      return (
        <div className="w-full py-[3rem] px-4 bg-white">
          <h1 className="py-5 md:text-5xl sm:text-6xl text-4xl font-bold md:py-10 font-genshin text-center">
            Characters
          </h1>
          <div className="w-[500px] md:w-[1200px] mx-auto grid grid-cols-1 md:grid-cols-5 gap-5">
            {characters.length ? (
              characters.map((character) => (
                <Link
                  key={character.id}
                  to={`/characters/${character.id}`}
                  className="w-full shadow-xl flex flex-col my-4 rounded-lg hover:scale-105 duration-300"
                >
                  <div
                    className={
                      character.rarity === 4
                        ? "bg-gradient-to-b from-[#5e5789] to-[#9c75b7]"
                        : character.rarity === 5
                        ? "bg-gradient-to-b from-[#945c2c] to-[#b27330]"
                        : ""
                    }
                  >
                    <img
                      className="w-[300px] h-[250px] object-cover"
                      src={character.icon}
                      alt={character.name + " Icon"}
                    />
                  </div>
                  <div>
                    <h4 className="text-2xl font-bold text-center py-8">
                      {character.name}
                    </h4>
                  </div>
                </Link>
              ))
            ) : (
              <div className="empty">
                <h2>No Characters Found!</h2>
              </div>
            )}
          </div>
        </div>
      );
    };
    

    CharacterCard.jsx - Read the provided validCharacterIds value provided by the Outlet Context and conditionally fetch the character details.

    import React, { useEffect, useState } from "react";
    import {
      useNavigate,
      Navigate,
      useParams,
      useOutletContext
    } from "react-router-dom";
    import { PuffLoader } from "react-spinners";
    
    const CDN_URL = "https://cdn.wanderer.moe/genshin-impact";
    const API_URL = "https://genshin.jmp.blue";
    
    export const CharacterCard = () => {
      const { validCharacterIds } = useOutletContext();
      const { id } = useParams();
    
      const [characterDetails, setCharacterDetails] = useState(null);
      const [characterImages, setCharacterImages] = useState([]);
      const [imageLoaded, setImageLoaded] = useState(false);
      const [isLoading, setIsLoading] = useState(true);
    
      const getCharacterDetails = async (characterName) => {
        setIsLoading(true);
        try {
          if (validCharacterIds.includes(id)) {
            const detailsResponse = fetch(`${API_URL}/characters/${characterName}`);
            const nameCardResponse = fetch(
              `${CDN_URL}/namecards/ui-namecardpic-${characterName}-p.png`
            );
            const iconResponse = fetch(
              `${CDN_URL}/character-icons/ui-avataricon-${characterName.toLowerCase()}.png`
            );
            const splashArtResponse = fetch(
              `${CDN_URL}/splash-art/${characterName.toLowerCase()}.png`
            );
            const characterCardResponse = fetch(
              `${CDN_URL}/character-cards/character-${characterName.toLowerCase()}-card.png`
            );
    
            const [
              details,
              nameCard,
              icon,
              splashArt,
              characterCard
            ] = await Promise.all([
              detailsResponse,
              nameCardResponse,
              iconResponse,
              splashArtResponse,
              characterCardResponse
            ]);
    
            const characterDetailsData = await details.json();
    
            let nobgImageData;
            let queryName;
            queryName = characterDetailsData.name.replace(" ", "-");
            let nobgImage = await fetch(
              `${CDN_URL}/character-icons/ui-avataricon-${queryName.toLowerCase()}.png`
            );
    
            if (nobgImage.ok) {
              nobgImageData = nobgImage.url;
            } else {
              nobgImage = await fetch(
                `${API_URL}/characters/${queryName.toLowerCase()}/icon-big`
              );
              // const nobgImage = await fetch(`${CDN_URL}/character-icons/ui-avataricon-${queryName.toLowerCase()}.png`);
              nobgImageData = nobgImage.url;
            }
    
            setCharacterImages({
              banner: nameCard.ok
                ? (await nameCard.url) || nameCard.headers.get("location")
                : "",
              icon: nobgImageData,
              splashArt: splashArt.url,
              characterCard: characterCard.url
            });
            setCharacterDetails(characterDetailsData);
            setImageLoaded(true);
    
            console.log("Character Details: ", characterDetailsData);
    
            // Set Page title as Character Name
            document.title = id;
          }
        } catch (error) {
          console.error("Error fetching character details:", error);
        } finally {
          setIsLoading(false);
        }
      };
    
      useEffect(() => {
        getCharacterDetails(id);
      }, [id]);
    
      if (isLoading) {
        return (
          <div className="flex justify-center items-center">
            <p>Fetching {id}</p>
            <PuffLoader color="#36d7b7" />
          </div>
        );
      }
    
      if (!characterDetails) {
        return <Navigate to="/404" replace />;
      }
    
      return (
        // Nothing changed in rendered content here
      );
    };
    

    App.jsx - Render CharactersLayout as a parent layout route to the two existing routes.

    import { Route, Routes, Navigate } from "react-router-dom";
    import { CharactersLayout } from "./CharactersLayout";
    import { Characters } from "./Characters";
    import { CharacterCard } from "./CharacterCard";
    import { NotFound } from "./NotFound";
    
    export default function App() {
      return (
        <div className="bg-[#1e1f21]">
          <Routes>
            <Route path="/characters" element={<CharactersLayout />}>
              <Route index element={<Characters />} />
              <Route path=":id" element={<CharacterCard />} />
            </Route>
            <Route path="404" element={<NotFound />} />
            <Route path="*" element={<Navigate to="/404" replace />} />
          </Routes>
        </div>
      );
    }
    

    Demo

    Edit using-uselocation-of-react-router-dom-to-receive-passed-data-in-react-results-in