Skip to main content
jg-packages

Search

    Layouts that restore scroll on Back and Forward, dialogs that close when the user navigates away, and titles from a route map, in React Router apps.

    A layout route that restores scroll

    An app shell whose header stays put while its pages change. The outlet takes the layout classes, and the settings section passes its own transitionKey, so moving between its tabs keeps the frame, its focus, and its scroll.

    tsx
    import { createBrowserRouter, Link } from 'react-router';
    import { PageOutlet } from 'jg-react-router';
    
    // the app's pages
    declare const ProjectsPage: () => null;
    declare const ProfileTab: () => null;
    declare const BillingTab: () => null;
    
    const AppShell = () => (
      <div className="flex min-h-dvh flex-col">
        <header className="border-b">
          <nav>
            <Link to="/">Projects</Link> <Link to="/settings/profile">Settings</Link>
          </nav>
        </header>
        <PageOutlet className="mx-auto flex max-w-5xl flex-1 flex-col px-4" />
      </div>
    );
    
    export const router = createBrowserRouter([
      {
        element: <AppShell />,
        children: [
          { path: '/', element: <ProjectsPage /> },
          {
            path: '/settings',
            element: <PageOutlet transitionKey="settings" />,
            children: [
              { path: 'profile', element: <ProfileTab /> },
              { path: 'billing', element: <BillingTab /> },
            ],
          },
        ],
      },
    ]);

    Import jg-react-router/styles.css once, such as in the app’s entry, for the frame’s width and fade. The outlet remembers the last 128 positions in memory, so a reload starts at the top.

    Closing dialogs when the route changes

    A confirmation dialog that should close when a link inside it, the Back button, or a redirect takes the user elsewhere. Its session is a new object each time it opens, so a navigation only closes the dialog that was open when it committed, never one opened after it.

    tsx
    import { useState } from 'react';
    import { createBrowserRouter, Outlet, RouterProvider } from 'react-router';
    import {
      DialogNavigationObserver,
      DialogNavigationProvider,
      useDialogNavigationDismissal,
    } from 'jg-react-router';
    
    // the app's pages
    declare const ProjectsPage: () => null;
    
    const ConfirmDialog = () => {
      const [session, setSession] = useState<{ message: string } | null>(null);
      useDialogNavigationDismissal({
        getSession: () => session,
        onDismiss: () => {
          setSession(null);
        },
      });
      return (
        <>
          <button
            type="button"
            onClick={() => setSession({ message: 'Archive this project?' })}
          >
            Archive
          </button>
          <dialog open={session !== null}>
            <p>{session?.message}</p>
            <button
              type="button"
              onClick={() => setSession(null)}
            >
              Cancel
            </button>
          </dialog>
        </>
      );
    };
    
    const router = createBrowserRouter([
      {
        path: '/',
        element: (
          <>
            <DialogNavigationObserver />
            <Outlet />
          </>
        ),
        errorElement: <DialogNavigationObserver isErrorScreen />,
        children: [{ index: true, element: <ProjectsPage /> }],
      },
    ]);
    
    export const App = () => (
      <DialogNavigationProvider>
        <RouterProvider router={router} />
        <ConfirmDialog />
      </DialogNavigationProvider>
    );

    The provider closes dialogs without touching history, so closing one never moves the user back. Inside a nested DialogNavigationProvider, dialogs follow only that provider’s observer.

    A document title from the route map

    Keep every page’s title and description next to its path, and set the document’s title and meta description from the matched page in one place, the root layout:

    tsx
    import { useEffect } from 'react';
    import { createBrowserRouter } from 'react-router';
    import {
      createPageRouteObject,
      type IPageRouteComponentProps,
      PageOutlet,
      usePageRouteMetadata,
    } from 'jg-react-router';
    
    const SettingsPage = ({ title }: IPageRouteComponentProps) => <h1>{title}</h1>;
    
    const RootLayout = () => {
      const pageMetadata = usePageRouteMetadata();
      useEffect(() => {
        document.title = pageMetadata === null ? 'Acme' : `${pageMetadata.title} | Acme`;
        document
          .querySelector('meta[name="description"]')
          ?.setAttribute('content', pageMetadata?.description ?? '');
      }, [pageMetadata]);
      return <PageOutlet />;
    };
    
    export const router = createBrowserRouter([
      {
        element: <RootLayout />,
        children: [
          createPageRouteObject({
            path: '/settings',
            title: 'Settings',
            description: 'Your account and notifications.',
            Component: SettingsPage,
          }),
        ],
      },
    ]);

    The page’s heading and the document’s title come from the same entry, so they can’t drift apart, and a route without page metadata, such as a redirect, leaves the default title.