# Use modular tabs

While it's typically recommended for a Squide application to maintain the boundary of a page within a single domain, there are situations where enhancing the user experience necessitates rendering a page with parts from multiple domains, or at the very least, simulating it 😊.

For this guide, we'll take as an example a page for which the parts that are owned by different domains are organized by tabs (modular tabs) and registered by different modules:

  • Tab 1: Registered by Remote Module 1
  • Tab 2: Registered by Remote Module 2
  • Tab 3: Registered by Local Module

Anatomy of a page rendering modular tabs
Anatomy of a page rendering modular tabs

# Define a nested layout

To build this page while adhering to Squide's constraint of avoiding hard references to elements from other modules, let's start by defining a React Router nested layout. This nested layout will handle rendering all the tab headers and the content of the active tab:

remote-module-3/src/tabs-layout.tsx
import { Suspense } from "react";
import { Link, Outlet } from "react-router-dom";

export function TabsLayout() {
    return (
        <div>
            <p>Every tab is registered by a different module.</p>
            <ul style={{ listStyleType: "none", margin: 0, padding: 0, display: "flex", gap: "20px" }}>
                <li><Link to="tabs/tab-1">Tab 1</Link></li>
                <li><Link to="tabs/tab-2">Tab 2</Link></li>
                <li><Link to="tabs/tab-3">Tab 3</Link></li>
            </ul>
            <div style={{ padding: "20px" }}>
                <Suspense fallback={<div>Loading...</div>}>
                    <Outlet />
                </Suspense>
            </div>
        </div>
    );
}

In the previous code sample, the TabsLayout component is similar to the RootLayout component introduced in previous guides. However, the key distinction is that this layout will be bound to the /tabs URL path. By nesting the layout under a specific path, it will only render when the user navigates to one of the modular tab pages (e.g. /tabs, /tabs/tab-1, /tabs/tab-2, /tabs/tab-3).

To register the newly created layout as a nested layout, use the registerRoute function:

remote-module-3/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { TabsLayout } from "./TabsLayout.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // Register the layout as a nested layout under the "/tabs" URL path.
        path: "/tabs",
        element: <TabsLayout />
    });

    runtime.registerNavigationItem({
        $id: "tabs",
        $label: "tabs",
        to: "/tabs"
    });
}

With this nested layout in place, thanks to the React Router Outlet component, the content of the tabs can now reside in distinct routes (registered by different modules) while still delivering a cohesive user experience. Whenever a user navigates between the tabs, the URL will be updated, and the tab content will change, but the shared portion of the layout will remain consistent.

As a bonus, each individual tab will have its own dedicated URL! 🥳

# Create the tab routes

Next, let's add the actual tabs to the modules. To do so, we'll use the parentPath option of the registerRoute function to register the routes under the TabsLayout component:

remote-module-1/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab1 } from "./Tab1.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // Using "index: true" instead of a path because this is the default active tab.
        index: true
        element: <Tab1 />
    }, { 
        parentPath: "/tabs"
    });
}
remote-module-1/src/Tab1.tsx
export function Tab1() {
    return (
        <div>Hey, this is Tab 1 content</div>
    );
}
remote-module-2/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab2 } from "./Tab2.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // React Router nested routes requires the first part of the "path" to be the same 
        // as the nested layout path (TabsLayout).
        path: "/tabs/tab-2"
        element: <Tab2 />
    }, { 
        parentPath: "/tabs"
    });
}
remote-module-2/src/Tab2.tsx
export function Tab2() {
    return (
        <div>Hey, this is Tab 2 content</div>
    );
}
local-module/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab3 } from "./Tab3.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // React Router nested routes requires the first part of the "path" to be the same 
        // as the nested layout path (TabsLayout).
        path: "/tabs/tab-3"
        element: <Tab3 />
    }, {
        parentPath: "/tabs"
    });
}
local-module/src/Tab3.tsx
export function Tab3() {
    return (
        <div>Hey, this is Tab 3 content</div>
    );
}

Now that the tabs has been registered, ensure that all four modules (including remote-modules-3) are registered in the host application. Start the development servers using the dev script. Navigate to the /tabs page, you should see the tab headers. Click on each tab header to confirm that the content renders correctly.

# Decouple the navigation items

Althought it's functional, the modules are currently coupled by hardcoded URLs within the TabsLayout component.

To decouple the navigation items, similar to what is done for regular module's routes, we'll use the registerNavigationItem function. In this case, we'll specify a menuId option. Defining the menuId option will allow the TabsLayout component to exclusively retrieve the navigation items that belongs to him.

First, let's register the navigation items with the menuId option. For this example the menuId will be /tabs (it can be anything):

remote-module-1/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab1 } from "./Tab1.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // Using "index: true" instead of a path because this is the default active tab.
        index: true
        element: <Tab1 />
    }, { 
        parentPath: "/tabs" 
    });

    runtime.registerNavigationItem({
        $id: "tab-1",
        $label: "Tab 1",
        to: "/tabs"
    }, { 
        // The menu id could be anything, in this example we are using the same path as the nested layout
        // path for convenience.
        menuId: "/tabs"
    });
}
remote-module-2/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab2 } from "./Tab2.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // React Router nested routes requires the first part of the "path" to be the same 
        // as the nested layout path (TabsLayout).
        path: "/tabs/tab-2"
        element: <Tab2 />
    }, { 
        parentPath: "/tabs"
    });

    runtime.registerNavigationItem({
        $id: "tab-2",
        $label: "Tab 2",
        to: "/tabs/tab-2"
    }, { 
        // The menu id could be anything, in this example we are using the same path as the nested layout
        // path for convenience.
        menuId: "/tabs"
    });
}
local-module/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab3 } from "./Tab3.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // React Router nested routes requires the first part of the "path" to be the same 
        // as the nested layout path (TabsLayout).
        path: "/tabs/tab-3"
        element: <Tab3 />
    }, { 
        parentPath: "/tabs"
    });

    runtime.registerNavigationItem({
        $id: "tab-3",
        $label: "Tab 3",
        to: "/tabs/tab-3"
    }, {
        // The menu id could be anything, in this example we are using the same path as the nested layout
        // path for convenience. 
        menuId: "/tabs" 
    });
}

Then, update the TabsLayout component to render the registered navigation items instead of the hardcoded URLs:

remote-module-3/src/tabs-layout.tsx
import { 
    useNavigationItems,
    useRenderedNavigationItems,
    type NavigationLinkRenderProps,
    type RenderItemFunction,
    type RenderSectionFunction
} from "@squide/react-router";
import { Suspense } from "react";
import { Link, Outlet } from "react-router-dom";

const renderItem: RenderItemFunction = (item, key) => {
    const { label, linkProps } = item as NavigationLinkRenderProps;

    return (
        <li key={key}>
            <Link {...linkProps}>
                {label}
            </Link>
        </li>
    );
};

const renderSection: RenderSectionFunction = elements => {
    return (
        <ul style={{ listStyleType: "none", margin: 0, padding: 0, display: "flex", gap: "20px" }}>
            {elements}
        </ul>
    );
};

export function TabsLayout() {
    const navigationItems = useNavigationItems({ menuId: "/tabs" });
    const renderedTabs = useRenderedNavigationItems(navigationItems, renderItem, renderSection);

    return (
        <div>
            <p>Every tab is registered by a different module.</p>
            {renderedTabs}
            <div style={{ padding: "20px" }}>
                <Suspense fallback={<div>Loading...</div>}>
                    <Outlet />
                </Suspense>
            </div>
        </div>
    );
}

# Change the display order of the tabs

Similarly to how the display order of regular navigation items can be configured, a modular tab position can be affected with the priority option.

To force Tab 3 to be positioned first, we'll give him a priority of 999:

local-module/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab3 } from "./Tab3.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        path: "/tabs/tab-3"
        element: <Tab3 />
    }, { 
        parentPath: "/tabs"
    });

    runtime.registerNavigationItem({
        $id: "tab-3",
        $label: "Tab 3",
        // Highest priority goes first.
        $priority: 999,
        to: "/tabs/tab-3"
    }, { 
        menuId: "/tabs" 
    });
}

# Try it 🚀

To ensure everything is still working correctly, start the development servers using the dev script and navigate to the /tabs page. You should see all three tabs, and you should be able to switch between them by clicking on the tab headers.

# Troubleshoot issues

If you are experiencing issues with this guide:

  • Open the DevTools console. You'll find a log entry for each registration that occurs and error messages if something went wrong:
    • [squide] The following route has been registered as a children of the "/tabs" route.
    • [squide] The following static navigation item has been registered to the "/tabs" menu for a total of 1 item.
  • Refer to a working example on GitHub.
  • Refer to the troubleshooting page.