nahuel.luca()

Route Modals with React Router

When we create modals that need to get data, we generally have 2 options the first is to get the data when the modal is open and the second is to get the data before. In the first option we have to create a states for the loader and for the data. In the second option we have to fetch all data before de modals is open but this is bad because we fetch all data even the user don't open de modal.

Routes Modals

There is a different approach using react router or remix nested routes. This approach consists of displaying the modal on a particular path, for example if we have a list of /pokemons we can make the modal display when the user clicks on one and is directed to /pokemons/ditto for example. This approach has some advantages when using it with react router which are the following:

User Experience

We do the data fetching at navigation time and not when the component is rendered along with this we can use skeletons or busy indicators for a better and faster user experience.

Readability

If you typically use useEffect() for data fetching you may have done something like the following:

export default function ModalSarasa() {
  const [isLoading, setIsLoading] = useState(true);
  const [data, setData] = useState([]);

  async function getData() {
    const pokemons = await fetch(
      "<https://pokeapi.co/api/v2/pokemon?limit=20&offset=0>"
    )
      .then((data) => data.json())
      .then((data) => data.results);
    setData(pokemons);
    setIsLoading(false);
  }

  useEffect(() => {
    getData();
  }, []);

  if (!isLoading) {
    return <div>Loading data...</div>;
  }

  return <Modal>{/* parse {data} here */}</Modal>;
}

This is a very common approach when no library is used for data fetching. This approach is fine but it tends to increase the boilerplate and overfetching because many do not handle the useEffect() dependencies well.

Easy to share

As it is a url the user can share the url and when entering it will be with the modal open without the user receiving the link having to do any other action.

Demo Time

We will create a small demo to see how it works. With a react + vite project already created and react router installed let's create the following createBrowserRouter in main.tsx

//main.tsx
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { createBrowserRouter, RouterProvider } from "react-router-dom";

// routes
import Pokemons, { pokemonsLoader } from "./routes/pokemons/index.tsx";
import PokemonModal, {
  pokemonModaLoader,
} from "./routes/pokemons/pokemon-modal/index.tsx";
import "bootstrap/dist/css/bootstrap.min.css";

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
  },
  {
    path: "/pokemons",
    loader: pokemonsLoader,
    element: <Pokemons />,
    children: [
      {
        path: ":pokemon-name",
        element: <PokemonModal />,
        loader: pokemonModaLoader,
      },
    ],
  },
]);

ReactDOM.createRoot(document.getElementById("root")!).render(
  <RouterProvider router={router} />
);

Now we will create our pokemons route that will bring us a list of 20 pokemons. Our pokemonsLoader will ask for the data which we will access from our Pokemons() component using the useLoaderData() hook, then we will map the information into our component and also use <Outlet /> which allows the nested UI to be displayed when the child routes are rendered.

// routes/pokemons/index.tsx
import { json, Outlet, useLoaderData } from "react-router";
import { Link } from "react-router-dom";

type Pokemon = {
  name: string;
  url: string;
};

interface IPokemons {
  pokemons: Pokemon[];
}

export async function pokemonsLoader() {
  const pokemons = await fetch(
    "https://pokeapi.co/api/v2/pokemon?limit=20&offset=0"
  )
    .then((data) => data.json())
    .then((data) => data.results);

  return json<IPokemons>({ pokemons: pokemons }, { status: 200 });
}

export default function Pokemons() {
  const { pokemons } = useLoaderData() as IPokemons;

  return (
    <section>
      <h2 className="text-2xl font-bold">See the first 20 pokemons</h2>
      <ul className="text-start list-disc pl-5">
        {pokemons.map((pokemon) => (
          <li className="my-2" key={pokemon.name}>
            <Link to={`${pokemon.name}`}>{pokemon.name}</Link>
          </li>
        ))}
      </ul>
      <Outlet />
    </section>
  );
}

Now we will create our nested path that will be displayed in /pokemons/:pokemon-name. This route will take care of getting the data for a specific pokemon using the pokemon-name parameter and display it in a modal.

//routes/pokemons/pokemon-modal/index.tsx
import {
  json,
  LoaderFunctionArgs,
  useLoaderData,
  useNavigate,
} from "react-router";
import Modal from "react-bootstrap/Modal";

type pokemonData = {
  capture_rate: number;
  is_legendary: boolean;
  is_mythical: boolean;
  color: string;
};

interface IPokemonData {
  name: string | undefined;
  info: pokemonData;
}

export async function pokemonModaLoader({ params }: LoaderFunctionArgs) {
  const pokemonName = params["pokemon-name"];
  const pokemonInfo = await fetch(
    `https://pokeapi.co/api/v2/pokemon-species/${pokemonName}`
  )
    .then((response) => response.json())
    .then((data) => ({
      capture_rate: data.capture_rate,
      is_legendary: data.is_legendary,
      is_mythical: data.is_mythical,
      color: data.color.name,
    }));

  return json<IPokemonData>(
    { name: pokemonName, info: pokemonInfo },
    { status: 200 }
  );
}

export default function PokemonModal() {
  const navigation = useNavigate();
  const handleClose = () => navigation(-1);
  const pokemon = useLoaderData() as IPokemonData;

  return (
    <>
      <Modal show={true} onHide={handleClose}>
        <Modal.Header closeButton>
          <Modal.Title>Data abount {pokemon.name}</Modal.Title>
        </Modal.Header>

        <Modal.Body>
          <h4>See Pokemon Info:</h4>
          <ul className="p-0">
            <li>
              <b>Capture rate:</b> {pokemon.info.capture_rate}
            </li>

            <li>
              <b>Color:</b> {pokemon.info.color}
            </li>

            <li>
              <b>Is Legendary:</b>
              {pokemon.info.is_legendary ? "Yes" : "No"}
            </li>

            <li>
              <b>Is Mysthical:</b>
              {pokemon.info.is_mythical ? "Yes" : "No"}
            </li>
          </ul>
        </Modal.Body>
        <Modal.Footer></Modal.Footer>
      </Modal>
    </>
  );
}

This is amazing, we were able to split the code and separate the code that gets the data and put it outside of our component and make it more readable. We also got rid of the annoying useStates().

But we can still add one more magic to improve the user experience. We can make our pokemonModaLoader return promises instead of resolved values with defer and together with the new <Suspense/> api of React show the user a skeleton if the internet connection is slow.

import {
  Await,
  defer,
  LoaderFunctionArgs,
  useLoaderData,
  useNavigate,
} from "react-router-dom";
import Modal from "react-bootstrap/Modal";
import { Suspense } from "react";

type PokemonData = {
  capture_rate: number;
  is_legendary: boolean;
  is_mythical: boolean;
  color: string;
};

interface IPokemonData {
  name: string | undefined;
  info: PokemonData;
}

export async function pokemonModaLoader({
  params,
}: LoaderFunctionArgs): Promise<ReturnType<typeof defer>> {
  const pokemonName = params["pokemon-name"];
  const pokemonInfo = fetch(
    `https://pokeapi.co/api/v2/pokemon-species/${pokemonName}`
  )
    .then((response) => response.json())
    .then((data) => ({
      capture_rate: data.capture_rate,
      is_legendary: data.is_legendary,
      is_mythical: data.is_mythical,
      color: data.color.name,
    }));

  return defer({
    name: pokemonName,
    info: pokemonInfo,
  });
}

const PokemonSkeleton = () => (
  <div className="animate-pulse">
    <div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
    <div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
    <div className="h-4 bg-gray-200 rounded w-2/3 mb-2"></div>
    <div className="h-4 bg-gray-200 rounded w-1/3"></div>
  </div>
);

const PokemonInfo = ({ info }: { info: PokemonData }) => (
  <>
    <h4>See Pokemon Info:</h4>
    <ul className="p-0">
      <li>
        <b>Capture rate:</b> {info.capture_rate}
      </li>
      <li>
        <b>Color:</b> {info.color}
      </li>
      <li>
        <b>Is Legendary:</b> {info.is_legendary ? "Yes" : "No"}
      </li>
      <li>
        <b>Is Mythical:</b> {info.is_mythical ? "Yes" : "No"}
      </li>
    </ul>
  </>
);

export default function PokemonModal() {
  const navigate = useNavigate();
  const handleClose = () => navigate(-1);
  const pokemon = useLoaderData() as IPokemonData;

  return (
    <Modal show={true} onHide={handleClose}>
      <Modal.Header closeButton>
        <Modal.Title>Data about {pokemon.name}</Modal.Title>
      </Modal.Header>
      <Modal.Body>
        <Suspense fallback={<PokemonSkeleton />}>
          <Await
            resolve={pokemon.info}
            errorElement={<p>Error loading pokemon information!</p>}
          >
            <PokemonInfo info={pokemon.info} />
          </Await>
        </Suspense>
      </Modal.Body>
    </Modal>
  );
}

In doing so, if we put our browser on a slower connection we will see a skeleton displayed while loading the data.

The end

You can see the code of the demo here and the deployed demo here. I hope this post has been helpful.