Search code examples
reactjsreact-routerheadless-ui

How to use Headless UI Tabs with React Router 6


I'm trying to create a reusable Tabs component with headless ui (https://headlessui.com/react/tabs) that works with react router

Here is a minimal reproducible example, clicking on a tab takes you to a new page when it should be changing the tab panel and keeping the actual tabs visible on the page, what am I missing?: https://codesandbox.io/s/3ogj23?file=/src/Components/TestComponents/TestComponents.tsx (edit - question resolved, thank you Drew Reese, working sandbox here)

This is the reusable component so far:

export interface TabData {
  route: string;
  label: string;
  component: React.ReactElement;
}    
  
export interface TabProps {
  tabInfo: TabData[];
  onTabChange: (selectedIndex: number) => void;
}
    
    function Tabs({ tabInfo, onTabChange }: TabProps) {
      const [selectedIndex, setSelectedIndex] = useState<number>(0);
    
      return (
        <div>
          <Tab.Group
            selectedIndex={selectedIndex}
            onChange={(selectedTabIndex: number) => {
              setSelectedIndex(selectedTabIndex);
              onTabChange(selectedTabIndex);
            }}
          >
            <Tab.List
              ---styling removed----
            >
              {tabInfo.map((item, index) => (
                <Tab
                  key={index}
                  aria-label={`${item.label}`}
                  name={`${item.label}`}
                  ---styling removed----
                >
                  {item.label}
                </Tab>
              ))}
            </Tab.List>
    
            <Tab.Panels>
              {tabInfo.map((item, index) => (
                <Tab.Panel key={index} className="bg-white p-4">
                  <div>{item.component}</div>
                </Tab.Panel>
              ))}
            </Tab.Panels>
          </Tab.Group>
        </div>
      );
    }
    
    export default Tabs;

And this is a snippet where I am implementing it in another component:

  import { useNavigate } from 'react-router-dom';     
    
    ----other code-------
    
    const navigate = useNavigate();
    
     return (
    
    ----other code-------
    
        const handleOnTabChange = (selectedIndex: number) => {
                
                const path = TabsData[selectedIndex].route;
            
                navigate(`/${path}`, {
                  // not sure this is needed
                  state: { tab: selectedIndex },
                  replace: false,
                });
              };
            
            
        ---irrelevent code removed---
                
           <Tabs tabInfo={TabsData} onTabChange={(selectedIndex) => handleOnTabChange(selectedIndex)} marginWidth={45} />

So basically, the reusable component returns the index of the selected tab so I can use that info to select the route I want from a an array (provided on implementation) like this:

export const TabsData = [
  {
    route: '',
    label: 'Tab 1',
    component: <Component1 />,
  },
  {
    route: 'tab-2-route',
    label: 'Tab 2',
    component: <Component2 />,
  },
  {
    route: 'tab-3-route',
    label: 'Tab 3',
    component: <Component3 />,
  },
  {
    route: 'tab-4-route',
    label: 'Tab 4',
    component: <Component4 />,
  },
];

This works in so far as I get the required path however, when i click on each individual tab I'm taken to the required component in a new browser window not the tab panel

How do I amend my reusable component or my implementation so that when I click on the required tab I get the component in the tab panel rather than a new page?


Solution

  • The TabsImplementation should be converted to a layout route such that it's wrapping the routes/tabs that it controls. As a layout route it renders an Outlet for nested routes to render their content into.

    Update the tab data to be more route friendly with element and path properties:

    import {
      ComponentOne,
      ComponentTwo,
      ComponentThree,
      ComponentFour
    } from "../Components/TestComponents/TestComponents";
    
    export const TabsData = [
      {
        path: "component-one-route",
        label: "Tab 1",
        element: <ComponentOne />
      },
      {
        path: "component-two-route",
        label: "Tab 2",
        element: <ComponentTwo />
      },
      {
        path: "component-three-route",
        label: "Tab 3",
        element: <ComponentThree />
      },
      {
        path: "component-four-route",
        label: "Tab 4",
        element: <ComponentFour />
      }
    ];
    

    TabsComponent - Renders the Outlet component in the Tab.Panel where the nested route that is matched will render its content.

    import { Tab } from "@headlessui/react";
    import { useState } from "react";
    import { Outlet } from "react-router-dom";
    
    export interface TabData {
      path: string;
      label: string;
      element: React.ReactElement;
    }
    
    export interface TabProps {
      tabInfo: TabData[];
      onTabChange: (selectedIndex: number) => void;
    }
    
    function Tabs({ tabInfo, onTabChange }: TabProps) {
      const [selectedIndex, setSelectedIndex] = useState<number>(0);
    
      return (
        <div>
          <Tab.Group
            selectedIndex={selectedIndex}
            onChange={(selectedTabIndex: number) => {
              setSelectedIndex(selectedTabIndex);
              onTabChange(selectedTabIndex);
            }}
          >
            <Tab.List>
              {tabInfo.map((item) => (
                <Tab key={item.path} aria-label={item.label} name={item.label}>
                  {item.label}
                </Tab>
              ))}
            </Tab.List>
    
            <Tab.Panels>
              {tabInfo.map((item) => (
                <Tab.Panel key={item.path} className="bg-white p-4">
                  <Outlet /> // <-- Outlet is tab panel content, route fills in
                </Tab.Panel>
              ))}
            </Tab.Panels>
          </Tab.Group>
        </div>
      );
    }
    
    export default Tabs;
    

    TabsImplementation

    import { useNavigate } from "react-router-dom";
    import Tabs from "../Components/TabsComponent";
    import { TabsData } from "../data/tabsData";
    
    export default function App() {
      const navigate = useNavigate();
    
      const handleOnTabChange = (selectedIndex: number) => {
        const { path } = TabsData[selectedIndex];
    
        navigate(path || "/");
      };
    
      return (
        <Tabs
          tabInfo={TabsData}
          onTabChange={(selectedIndex) => handleOnTabChange(selectedIndex)}
        />
      );
    }
    

    App - maps the tabs data to nested routes rendered in the TabsImplementation layout route.

    import "./styles.css";
    import { Routes, Route, Navigate } from "react-router-dom";
    import TabsImplementation from "./pages/TabsImplementation";
    import { TabsData } from "./data/tabsData";
    
    export default function App() {
      return (
        <div className="App">
          <h1>Tabs with Routing with headless-ui</h1>
    
          <Routes>
            <Route element={<TabsImplementation />}>
              {TabsData.map(({ element, path }) => (
                <Route key={path} {...{ element, path }} />
              ))}
              <Route
                path="*"
                element={<Navigate to={TabsData[0].path} replace />}
              />
            </Route>
          </Routes>
        </div>
      );
    }
    

    Edit how-to-use-headless-ui-tabs-with-react-router-6