Search code examples
reactjstypescriptreact-router

React Router: Nested Route Not Rendering -- How to structure properly?


I'm working on a React application using React-Router for navigation. I have a nested routing setup for displaying scoreboards where you can click on it to get the full details, but I'm facing an issue where the ScoreDetailsTab component does not render when its route is accessed.

I have these main routes:

<WrappedRoute
  path="/games/:gameName"
  page={GamesPage}
  component={GamePage}
  publicRoute
/>

<WrappedRoute
  path="/games"
  page={GamesPage}
  component={Games}
  content={children}
  publicRoute
/>

and that basically is just a page where you can click on a game (i.e. League of Legends or Valorant) and view the scores of professional matches. The games are just represented as links:

import React from 'react';
import { Link } from 'react-router-dom';
import gameConfig from 'src/game-config';

const Games: React.FC = () => {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
      {gameConfig.map((game) => (
        <Link key={game.path} to={`/games/${game.path}`} className="block p-4 bg-blue-500 text-white rounded-lg">
          <div className="flex flex-col items-center">
            <h2 className="text-xl font-bold">{game.name}</h2>
          </div>
        </Link>
      ))}
    </div>
  );
};

export default Games;

This all works as expected. The issue begins to arise in the GamePage component:

import React from 'react';
import { Route, Switch, Redirect, useParams } from 'react-router-dom';
import gameConfig from 'src/game-config';
import GamePageMenu from 'src/components/GamePageMenu';
import { CommunityTab, ScoreDetailsTab, ScoresTab, StandingsTab, StatsTab, FantasyTab, MediaTab } from 'src/features/AsyncComponents';

const GamePage: React.FC = () => {
  const { gameName } = useParams<{ gameName: string }>();
  const game = gameConfig.find(g => g.path === gameName);

  if (!game) {
    return <div className="text-center text-red-500">Invalid game name</div>;
  }

  return (
    <div className="flex flex-col items-center">
      <GamePageMenu />
      <Switch>
        <Route path={`/games/:gameName/community`} component={CommunityTab} />
        <Route path={`/games/:gameName/scores/:gameId`} component={ScoreDetailsTab} />
        <Route path={`/games/:gameName/scores`} component={ScoresTab} />
        <Route path={`/games/:gameName/standings`} component={StandingsTab} />
        <Route path={`/games/:gameName/stats`} component={StatsTab} />
        <Route path={`/games/:gameName/media`} component={MediaTab} />
        <Redirect exact from="/games/:gameName" to="/games/:gameName/community" />
      </Switch>
    </div>
  );
};

export default GamePage;

The GamePageMenu allows us to navigate between these different routes and so the GamePage works as expected.

import React from 'react';
import { Link, useParams } from 'react-router-dom';
import gameConfig from 'src/game-config';

const GamePageMenu: React.FC = () => {
  const { gameName } = useParams<{ gameName: string }>();
  const game = gameConfig.find(g => g.path === gameName);

  if (!game) {
    return null;
  }

  const tabs = game.isEsport
    ? ['Community', 'Scores', 'Standings', 'Stats', 'Media']
    : ['Community', 'Media'];

  return (
    <div className="flex justify-center mb-4 space-x-4 border-b">
      {tabs.map((tab) => (
        <Link
          key={tab}
          to={`/games/${gameName}/${tab.toLowerCase()}`}
          className="px-4 py-2 text-gray-700 hover:text-blue-500 relative"
        >
          {tab}
        </Link>
      ))}
    </div>
  );
};

export default GamePageMenu;

The conditional rendering and everything works as expected. So we go to the ScoresTab tab and we have these Scoreboards displayed.

import React from 'react';
import { useParams, Link } from 'react-router-dom';
import { initialLoLScoreboardState } from 'src/slices/lol-scoreboard';
import { initialValorantScoreboardState } from 'src/slices/valorant-scoreboard';
import { getScoreboardComponent } from 'src/components/Scoreboard';
import gameConfig from 'src/game-config';

const ScoresTab: React.FC = () => {
  const { gameName } = useParams<{ gameName: string }>();
  const game = gameConfig.find(g => g.path === gameName);

  if (!game) {
    return <div className="text-center text-red-500">Invalid game name</div>;
  }

  const ScoreboardComponent = getScoreboardComponent(game.path);

  const renderScoresContent = () => {
    if (!ScoreboardComponent) {
      return <div>Scoreboard component not found for {game.name}</div>;
    }

    switch (game.path) {
      case 'lol': {
        const { games } = initialLoLScoreboardState;
        return (
          <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
            {games.map((game) => (
              <Link key={game.id} to={`/games/${gameName}/scores/${game.id}`}>
                <ScoreboardComponent
                  gameId={game.id}
                  team1={game.team1}
                  team2={game.team2}
                  seriesInfo={game.seriesInfo}
                  gameNumber={game.gameNumber}
                  leadingTeam={game.leadingTeam}
                  leadingScore={game.leadingScore}
                />
              </Link>
            ))}
          </div>
        );
      }
      case 'valorant': {
        const { games } = initialValorantScoreboardState;
        return (
          <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
            {games.map((game) => (
              <Link key={game.id} to={`/games/${gameName}/scores/${game.id}`}>
                <ScoreboardComponent
                  gameId={game.id}
                  team1={game.team1}
                  team2={game.team2}
                  matchInfo={game.matchInfo}
                />
              </Link>
            ))}
          </div>
        );
      }
      default:
        return <div>Unsupported game type for scores content</div>;
    }
  };

  return <div>{renderScoresContent()}</div>;
};

export default ScoresTab;

Again, this all works as expected. The type of scoreboard is conditionally rendered depending on which menu we're in, and they are clickable links. However, actually navigating to the links poses the issue.

import React from 'react';
import { useParams } from 'react-router-dom';

const ScoreDetailsTab: React.FC = () => {
  const { gameId } = useParams<{ gameId: string }>();
  console.log("rendering score details for gameId:", gameId);
  return <div>Score Details for gameId {gameId}</div>;
};

export default ScoreDetailsTab;

We expect to see the div here rendered and at least for the console log statement to execute. However, they do not. We are navigated to the correct route as reflected in the url, but nothing is rendered. I am confused because we define both the route and the link to the route.

I've tried multiple different approaches at this point, such as defining the routes in GamePage as part of my main routes, but that didn't solve the issue and also led to other problems.

At this point, I just conceptually don't understand why it isn't working. Any help is appreciated, and if there is a better way to structure what I'm trying to do I'd love to hear it.


Solution

  • As far as i know using useParams for (deeply) nested routes:

    <Route path={`/games/:gameName/scores/:gameId`} component={ScoreDetailsTab} />
    

    is not a good practice, so i think using a useOutletContext would help to get your problem resolved which makes the code maintenance more explicit and easy i would say.

    Try this out:

    interface OutletContext {
      games: {
        gameId: string
      }
    }
    
    const ScoreDetailsTab: React.FC = () => {
      const { games } = useOutletContext() as OutletContext;
      console.log("rendering score details for gameId:", games.gameId);
      
      return(
        <section> 
          <div> {games.gameId} </div>
        </section>
      );
    };
    

    In your main App component, make sure to set up the routes correct:

    const App: React.FC = () => {
      return (
        <Routes>
            <Route path="/games">
               <Route index element={<DefaultPage />} /> // create a new page
                 <Route path=":gameName" element={<GamePage />}>
                 <Route index element={<GameOverview />} />
                 <Route path="community" element={<CommunityTab />} />
                 <Route path="scores" element={<ScoresTab />} />
                 <Route path="scores/:gameId" element={<ScoreDetailsTab />} />
                 <Route path="standings" element={<StandingsTab />} />
                 <Route path="stats" element={<StatsTab />} />
                 <Route path="media" element={<MediaTab />} />
                </Route>
            </Route>
          </Route>
        </Routes>
      );
    }
    

    And finish up by cleaning the GamePage component return statement:

     return (
        <div className="flex flex-col items-center">
          <GamePageMenu/>
          <Outlet context={{ games }} />
        </div>
      );
    

    Resource: how to pass data using outlet context