Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Usage with Storybook? #952

Unanswered
MerlinMason asked this question in Q&A
Jan 8, 2024 · 13 comments · 22 replies
Discussion options

Hi there,

We're currently using React-Router and considering migrating to Tanstack Router...

We use Storybook a lot and we needed to use an add-on to enable React Router to work with Storybook.

Is this also the case with Tanstack Router? and if so does such an add-on exist yet?

Thanks!

You must be logged in to vote

Replies: 13 comments · 22 replies

Comment options

Currently with Storybook I get this error on most stories: ReferenceError: Cannot access 'Root' before initialization
I'm on @tanstack/react-router: "1.4.6",
Haven't been able to figure it out

You must be logged in to vote
1 reply
@erickarnis-tb
Comment options

I figured it out. Importing anything from a router file leads to the execution of the router file which can lead to errors even in unrelated stories.

Comment options

I would like to know how to use Storybook as well. I think a plugin would be great, or simply a working solution how to create the router context for stories that you can display components which includes any API of the router

You must be logged in to vote
3 replies
@Dixtir
Comment options

@dohomi have you found a solution for this?

@dohomi
Comment options

I use this:

const rootRoute = new RootRoute()
const indexRoute = new Route({ getParentRoute: () => rootRoute, path: "/" })
const memoryHistory = createMemoryHistory({ initialEntries: ["/"] })
const routeTree = rootRoute.addChildren([indexRoute])
const router = new Router({ routeTree, history: memoryHistory })

// @ts-ignore todo maybe soon a better solution for this?
export const withSbTanstackRouter: Preview["decorators"][0] = (Story, context) => {
  return <RouterProvider router={router} defaultComponent={() => <Story {...context} />} />
}

then wrap your most outer global decorator story like this:

export const SbDecorator: Decorator = (Story,args) => (
  <>{withSbTanstackRouter(Story,args))}</>
)
@Dixtir
Comment options

@dohomi thanks, much appreciated!

Comment options

I'm interested to know if there are any workaround/solutions to render the Link component inside a story. Some components might want to use router hooks even.

You must be logged in to vote
1 reply
@Dixtir
Comment options

@alireza-mh have you found a solution for this?

Comment options

Here's what I created: Storybook fake Tanstack router - Gist

You must be logged in to vote
0 replies
Comment options

Here is what I've done

import type {PartialStoryFn, StoryContext} from '@storybook/types';
import {createMemoryHistory, createRootRoute, createRoute, createRouter, RouterProvider} from '@tanstack/react-router';

export default function withRouter(Story: PartialStoryFn, {parameters}: StoryContext) {
	const {initialEntries = ['/'], initialIndex, routes = ['/']} = parameters?.router || {};

	const rootRoute = createRootRoute();

	const children = routes.map((path) =>
		createRoute({
			path,
			getParentRoute: () => rootRoute,
			component: Story,
		}),
	);

	rootRoute.addChildren(children);

	const router = createRouter({
		history: createMemoryHistory({initialEntries, initialIndex}),
		routeTree: rootRoute,
	});

	return <RouterProvider router={router} />;
}

declare module '@storybook/types' {
	interface Parameters {
		router?: {
			initialEntries?: string[];
			initialIndex?: number;
			routes?: string[];
		};
	}
}

You can add it as global decorator in preview.tsx file.

You must be logged in to vote
8 replies
@boonya
Comment options

@wurmr , I have 1.32.3 and it still works fine

@d4vidsha
Comment options

@boonya Can you explain further how to achieve this for newbies? I am one of them.

@boonya
Comment options

@d4vidsha what do you mean by "achieve this"? I have it working if it doesn't? I don't know. It depends on many factors - set of dependencies, storybook setup, browser and many others.

@d4vidsha
Comment options

@boonya Ah I was just wondering where the above code snippet should be placed more generally, as I'm a little lost even after reading up on the docs for Storybook decorators. Just trying to link your comment and those docs together in my head to create a minimum reproducible example.

@boonya
Comment options

I would recommend you remove as many as you can and leave the only code related to router. Then check how it work. If does, add other feature one by one to catch the guilty one.

Comment options

Here's something simple that's been working fine with router v1.4 and storybook v7.6. You can see it has no business logic at all, but it's enough to provide the router context so things don't break.

// preview.tsx

import type { Preview } from '@storybook/react';
import { RouterProvider, createMemoryHistory, createRootRoute, createRouter } from '@tanstack/react-router';

const preview: Preview = {
  decorators: [
    (Story) => {
      return <RouterProvider router={createRouter({
        history: createMemoryHistory(),
        routeTree: createRootRoute({
          component: Story
        })
      })} />
    }
  ]
};

export default preview
You must be logged in to vote
3 replies
@boonya
Comment options

Okay I see, but how about the situation you need to render your component by the specific route? How can I do that?

@bhaugeea
Comment options

Not sure, but in my opinion a component like that doesn't belong in storybook. I use storybook for things that are supposed to be generically composable. Buttons, modals, dropdowns, cards, etc. The only story I currently have that relies on router is a reusable implementation of tanstack table that puts its pagination, sorting, and filtering state in the URL. Doesn't care what route it's on, it just adds its own state.

If you need to recreate some non-trivial route tree, data fetching, and other stuff to make your stories work, then either: (a) you're trying to take storybook too far or (b) your components manage state badly and need to be written cleaner to be truly reusable.

@boonya
Comment options

"Too far" is too abstract statemnt. If you work with components library, you are correct. But what if you want to implement some component of real application which is dependent on router context as well as some data API. Does that mean I should not do so? But why? Storybook is amazing tool which gives me such ability throught it's decorators API and parameters.
So, what is "too far" to you may be "not enough" to someone other.
With that toolset storybook helps me implement frontend far ahead of backend in my projects. It save us a tone of time and make my team free of redundant headache.

Comment options

Just FYI, there's quite a bit of subtlety involved with the Storybook (8) integration, the simple solution I and others have pasted above does not always work properly when switching between stories:

  • it will keep a stale component (not re-rendered)
  • once you navigate away from the default route, it will not navigate back
You must be logged in to vote
0 replies
Comment options

I bypassed the RouterProvider and used the RouterContextProvider and so far it works well. Can't promise it wouldn't break in a future version or that it works in all cases.
In the preview.ts file add a decorator, like:

  const router = createRouter({ routeTree });
  ...
  decorators: [story => <RouterContextProvider router={router}>{story()}</RouterContextProvider>]
You must be logged in to vote
1 reply
@jaens
Comment options

This will not work when the story actually navigates anywhere (eg. in more "system-level" stories / form submits).

(note that changing eg. hash parameters due to eg. radio group selection is also considered a navigation, but depending on the exact state management setup it might still work)

Comment options

I tried all the above ways but it all looses either strong typings on useSearch or useParams and the user is forced to relax the typings with strict: false which is not really ideal. I would like to display landing pages of tanstack router pages and if there is any business logic involved which is strongly typed I receive error messages.

Invariant failed: Could not find a nearest match!
Invariant failed: Could not find an active match from "/_dashboard/team/$teamId/players"

I start to provide my own useSearch which basically keeps the same typings but replaces the options with {strict:false}

import {
  type AnyRoute,
  type FullSearchSchema,
  type RegisteredRouter,
  type RouteById,
  type RouteIds,
  useSearch
} from "@tanstack/react-router"
import { isStorybook } from "../lib/generalConfig"

type StringLiteral<T> = T extends string ? (string extends T ? string : T) : never

type StrictOrFrom<TFrom, TStrict extends boolean = true> = TStrict extends false
  ? {
      from?: never
      strict: TStrict
    }
  : {
      from: StringLiteral<TFrom> | TFrom
      strict?: TStrict
    }

type UseSearchOptions<TFrom, TStrict extends boolean, TSearch, TSelected> = StrictOrFrom<
  TFrom,
  TStrict
> & {
  select?: (search: TSearch) => TSelected
}
type Expand<T> = T extends object
  ? T extends infer O
    ? O extends Function
      ? O
      : { [K in keyof O]: O[K] }
    : never
  : T

/**
 * Enhanced useSearch hook that optionally disables strict mode if running in Storybook.
 */
export function usePtSearch<
  TRouteTree extends AnyRoute = RegisteredRouter["routeTree"],
  TFrom extends RouteIds<TRouteTree> = RouteIds<TRouteTree>,
  TStrict extends boolean = true,
  TSearch = TStrict extends false
    ? FullSearchSchema<TRouteTree>
    : Expand<RouteById<TRouteTree, TFrom>["types"]["fullSearchSchema"]>,
  TSelected = TSearch
>(opts: UseSearchOptions<TFrom, TStrict, TSearch, TSelected>): TSelected {
  // Create a modified options object if in Storybook mode

  // Call the useSearch hook with the appropriate options
  return useSearch(isStorybook ? { strict: false } : opts)
}
You must be logged in to vote
4 replies
@hiima
Comment options

@dohomi
How is ../lib/generalConfig implemented? I couldn't figure out how to detect if Storybook is running.

@dohomi
Comment options

its simply a process.env.STORYBOOK lookup. I set this to truthy

@hiima
Comment options

Got it, thanks!

@jmurga97
Comment options

I've been working on this for days. One thing that i found is how /_dashboard/team/$teamId/player is diferrent from /_dashboard/team/$teamId/player/. If this file is a index one on a file based routing the custom hooks inside wont recognize the actual route until is proper configured

Comment options

Here's what worked for me on a project with file-based routing. I simply create a router with a route for the story, and depending on the route I render a passthrough layout route if there's any passless (layout) on the route. It defaults to an index route for stories of components with no strict route APIs. I do on this component:

export function StoryRouterProvider(
  props: PropsWithChildren<{
    parameters: StoryContext["parameters"];
  }>,
): ReactElement {
  const { children, parameters } = props;
  let storyRoutePath = "/";
  let layoutRouteId;

  // If the route parameter exists, extract any layout segment from the path,
  // if there's any layout segment, a layout route will be created as a parent of the story route
  if (parameters["route"]) {
    const pathSegments = parameters["route"].id.split("/").filter((e) => e);
    const layoutSegments = pathSegments.slice(
      0,
      pathSegments.findLastIndex((s) => s.startsWith("_")) + 1,
    );
    layoutRouteId = layoutSegments.join("/") || undefined;
    storyRoutePath = "/" + pathSegments.slice(layoutSegments.length).join("/");
  }

  const rootRoute = createRootRoute({});
  let layout =
    layoutRouteId &&
    createRoute({
      component: Outlet,
      id: layoutRouteId,
      getParentRoute: () => rootRoute,
    });
  const storyRoute = createRoute({
    component: () => children,
    path: storyRoutePath,
    getParentRoute: () => (layout ? layout : rootRoute),
  });
  layout?.addChildren([storyRoute]);
  rootRoute.addChildren([layout ? layout : storyRoute]);

  const finalPath = parameters["route"]?.params
    ? interpolatePath({
        path: storyRoutePath,
        params: parameters["route"].params,
      }).interpolatedPath
    : storyRoutePath;

  const router = createRouter({
    history: createMemoryHistory({
      initialEntries: [finalPath],
    }),
    routeTree: rootRoute,
  });

  return <RouterProvider router={router} defaultComponent={() => children} />;
}

Then I wrap the Story decorator on preview.js with the above component, then on the stories files, if a component uses route-specific APIs like getRouteApi, I provide a route parameter with the route ID and values for path parameters if there's any, for example

  parameters: {
    route: {
      id: "_authenticated/users/$userId/profile",
      params: { userId: 1 },
    },
  }
You must be logged in to vote
0 replies
Comment options

Hey guys, based on the previous answers i came up with a solution that supports the use of hooks, layouts and dynamic parameters.

I just wish to find a way to handle navigation flows based on the array entries and the generated route tree like this example with react router and mock service worker: https://github.com/mswjs/msw-storybook-addon/blob/main/packages/docs/src/demos/react-router-react-query/App.stories.tsx

export interface RouteData {
  path: string;
  loader?: Record<string, unknown>;
}

interface InitialEntries {
  entry: string;
  params?: Record<string, string>;
}

interface RouterParameters {
  initialEntries?: InitialEntries[];
  routes?: RouteData[];
}

const defaultRoute = [{ path: '/' }];
const defaultInitialEntries = [{ entry: '/', params: undefined }];

export function withTanstackRouter(Story: PartialStoryFn, { parameters }: StoryContext) {
  const { initialEntries = defaultInitialEntries, routes = defaultRoute } = parameters?.router || {};
  const rootRoute = createRootRoute({});

  routes.forEach((route) => {
    const { parentRoute, storyRoute } = createRouteConfig(route, Story, rootRoute);

    if (parentRoute !== rootRoute) rootRoute.addChildren([parentRoute]);
    parentRoute.addChildren([storyRoute]);
  });

  // Process entries with dynamic parameters
  const processedEntries = initialEntries.map(({ entry, params }) => {
    if (params) {
      return interpolatePath({
        path: entry,
        params
      }).interpolatedPath;
    }
    return entry;
  });

  const router = createRouter({
    history: createMemoryHistory({
      initialEntries: processedEntries,
      initialIndex: 0
    }),
    routeTree: rootRoute
  });

  return <RouterProvider router={router}></RouterProvider>;
}

declare module '@storybook/types' {
  interface Parameters {
    router?: RouterParameters;
  }
}

/**
 * Creates route configuration for Storybook stories
 * @param {RouteData} route - Full story path pattern and loader if exists
 * @param {PartialStoryFn} Story - Storybook component
 * @param {RootRoute} rootRoute - Application root route
 * @returns {Object} Contains layout and story routes
 */

export function createRouteConfig(route: RouteData, Story: PartialStoryFn, rootRoute: RootRoute) {
  const { layoutRouteId, storyRoutePath } = parseRoutePath(route.path);

  const parentRoute = layoutRouteId
    ? createRoute({
        id: layoutRouteId,
        component: Outlet,
        getParentRoute: () => rootRoute
      })
    : rootRoute;

  const storyRoute = createRoute({
    component: Story,
    path: `${storyRoutePath}`,
    getParentRoute: () => parentRoute,
    loader: () => route.loader
  });

  return { parentRoute, storyRoute };
}

/**
 * Finds the index of the last layout segment in a route.
 * @param segments - Array of route segments.
 * @returns The index of the last layout found, or -1 if no layouts are present.
 */
const findLastLayoutIndex = (segments: string[]): number => {
  let lastLayoutIndex = -1;

  for (let i = segments.length - 1; i >= 0; i--) {
    if (segments[i].startsWith('_')) {
      lastLayoutIndex = i;
      break;
    }
  }
  return lastLayoutIndex;
};

/**
 * Parses a route and separates layout and story components.
 * @param path - Route to parse.
 * @returns Object with the layout route or undefined, and the story route path.
 * @throws Error if the path is invalid.
 */
export function parseRoutePath(path: string): RouteParseResult {
  if (!path || typeof path !== 'string') {
    throw new Error('Invalid path: must be a non-empty string');
  }
  const hasTrailingSlash = path.endsWith('/');

  const pathSegments = path.split('/').filter(Boolean);

  const layoutMarkerIndex = findLastLayoutIndex(pathSegments);
  const hasLayout = layoutMarkerIndex !== -1;

  const boundaryIndex = layoutMarkerIndex + 1;
  const layoutSegments = hasLayout ? pathSegments.slice(0, boundaryIndex) : [];
  const storySegments = hasLayout ? pathSegments.slice(boundaryIndex) : [...pathSegments];

  const layoutRouteId = layoutSegments.length ? `/${layoutSegments.join('/')}` : undefined;
  let storyRoutePath = storySegments.length ? `/${storySegments.join('/')}` : '/';

  if (hasTrailingSlash && storyRoutePath !== '/') storyRoutePath += '/';

  return {
    layoutRouteId,
    storyRoutePath
  };
}

Usage

Basic Route

export const Basic = {
  parameters: {
    router: {
      routes: [{ path: '/dashboard' }],
      initialEntries: [{ entry: '/dashboard' }]
    }
  }
};

Route with Dynamic Parameters

export const WithParams = {
  parameters: {
    router: {
      routes: [{ path: '/users/:id' }],
      initialEntries: [
        {
          entry: '/users/:id',
          params: { id: '123' }
        }
      ]
    }
  }
};

Route with Dynamic Parameters and loader

export const WithParams = {
  parameters: {
    router: {
      routes: [
        { 
          path: '/_layout/users/:id',
          loader: {
            user: {id: '123', name:'Lorem Ipsum'}
          }
        }
      ],
      initialEntries: [
        {
          entry: '/users/:id',
          params: { id: '123' }
        }
      ]
    }
  }
};
You must be logged in to vote
0 replies
Comment options

import { Decorator } from '@storybook/react';
import {
createRootRoute,
createRouter,
RouterProvider
} from '@tanstack/react-router';

export const RouterDecorator: Decorator = (Story) => {
const rootRoute = createRootRoute({
component: () => <Story />
});

const routeTree = rootRoute;

const router = createRouter({
routeTree
});

return <RouterProvider router={router} />;
};
You must be logged in to vote
1 reply
@Shuunen
Comment options

For those who want to check the whole file, here's my .storybook/preview.tsx file based on @llite22 suggestion :

import type { Decorator, Preview } from "@storybook/react";
import { createRootRoute, createRouter, RouterProvider } from "@tanstack/react-router";
import React from "react";
import "../src/index.css";

const RouterDecorator: Decorator = (Story) => {
  const rootRoute = createRootRoute({ component: () => <Story /> });
  const routeTree = rootRoute;
  const router = createRouter({ routeTree });
  return <RouterProvider router={router} />;
};

const preview: Preview = {
  decorators: [RouterDecorator],
};

export default preview;

Works great thanks <3

Comment options

I just decided to load the entire routeTree 🤷

import type { Meta, StoryObj } from '@storybook/react-vite';
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
import { expect, userEvent, within } from 'storybook/test';
import { routeTree } from '../../routeTree.gen';

type Args = {
  initialEntries: string[];
  initialIndex: number;
};

function Routes({ initialEntries, initialIndex }: Args) {
  const router = createRouter({
    history: createMemoryHistory({ initialEntries, initialIndex }),
    routeTree,
  });

  return <RouterProvider router={router} />;
}

// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = {
  title: 'Routes/Enroll',
  component: Routes,
  args: {
    initialIndex: 0,
  },
} satisfies Meta<typeof Routes>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Success: Story = {
  args: {
    initialEntries: ['/enroll'],
  },
  async play({ canvasElement }) {
    const { findByRole, findByText } = within(canvasElement);
    await userEvent.type(await findByRole('textbox', { name: 'Enrollment code' }), '1234{enter}');
    await expect(await findByText('Computer successfully enrolled:')).toBeInTheDocument();
  },
};

Definitely is a bit more integration testing-ish, but I can just set the proper intitialEntries and go from there.

You must be logged in to vote
0 replies
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
🙏
Q&A
Labels
None yet
Morty Proxy This is a proxified and sanitized view of the page, visit original site.