-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Usage with Storybook? #952
-
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!
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 2
Replies: 15 comments 27 replies
-
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
Beta Was this translation helpful? Give feedback.
All reactions
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1 -
❤️ 1
-
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
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 1
-
@dohomi have you found a solution for this?
Beta Was this translation helpful? Give feedback.
All reactions
-
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))}</> )
Beta Was this translation helpful? Give feedback.
All reactions
-
🎉 5 -
❤️ 1
-
@dohomi thanks, much appreciated!
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 1
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
@alireza-mh have you found a solution for this?
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 2
-
Here's what I created: Storybook fake Tanstack router - Gist
Beta Was this translation helpful? Give feedback.
All reactions
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 10 -
🎉 13 -
❤️ 1
-
@wurmr , I have 1.32.3 and it still works fine
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1
-
@boonya Can you explain further how to achieve this for newbies? I am one of them.
Beta Was this translation helpful? Give feedback.
All reactions
-
@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.
Beta Was this translation helpful? Give feedback.
All reactions
-
@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.
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 1
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 1
-
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
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1 -
👎 3
-
Okay I see, but how about the situation you need to render your component by the specific route? How can I do that?
Beta Was this translation helpful? Give feedback.
All reactions
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
"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.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 4
-
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
Beta Was this translation helpful? Give feedback.
All reactions
-
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>]
Beta Was this translation helpful? Give feedback.
All reactions
-
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)
Beta Was this translation helpful? Give feedback.
All reactions
-
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) }
Beta Was this translation helpful? Give feedback.
All reactions
-
@dohomi
How is ../lib/generalConfig implemented? I couldn't figure out how to detect if Storybook is running.
Beta Was this translation helpful? Give feedback.
All reactions
-
its simply a process.env.STORYBOOK lookup. I set this to truthy
Beta Was this translation helpful? Give feedback.
All reactions
-
Got it, thanks!
Beta Was this translation helpful? Give feedback.
All reactions
-
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
Beta Was this translation helpful? Give feedback.
All reactions
-
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 }, }, }
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 1
-
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' } } ] } } };
Beta Was this translation helpful? Give feedback.
All reactions
-
🎉 1
-
Nice! By the way, you can improve the type safety of the types using the FileRoutesByFullPath type exported from the routeTree.gen file:
// take an intersection and combine it for better hovers export type Compute<T> = { [K in keyof T]: T[K] } & unknown; export type IfMaybeUndefined<T, True, False> = undefined extends T ? True : False; export type RequiredKeys<T> = { [K in keyof T]: undefined extends T[K] ? never : K; }[keyof T]; export type HasRequiredKeys<T, True, False> = RequiredKeys<T> extends never ? False : True; export type RouteData = { [Path in keyof FileRoutesByFullPath]: Compute< { path: Path } & IfMaybeUndefined< // require loader data if we need it (i.e. it can't be undefined) FileRoutesByFullPath[Path]["types"]["loaderData"], { loaderData?: FileRoutesByFullPath[Path]["types"]["loaderData"]; }, { loaderData: FileRoutesByFullPath[Path]["types"]["loaderData"]; } > >; }[keyof FileRoutesByFullPath]; export type InitialEntries = { [Path in keyof FileRoutesByFullPath]: Compute< { entry: Path } & HasRequiredKeys< // require params if we have any (no need to provide {}) FileRoutesByFullPath[Path]["types"]["params"], { params: FileRoutesByFullPath[Path]["types"]["params"]; }, { params?: FileRoutesByFullPath[Path]["types"]["params"]; } > >; }[keyof FileRoutesByFullPath];
This means you'll get proper type checking for loader data and params (and errors if they're wrong or missing).
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 1
-
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} />; };
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 2
-
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
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 1
-
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} />; };
I love you <3
Beta Was this translation helpful? Give feedback.
All reactions
-
Work great, until you use import { getRouteApi } from "@tanstack/react-router" in your component.
Any way to make Storybook work when using hooks like Route.useSearch()?
Beta Was this translation helpful? Give feedback.
All reactions
-
@theoludwig Read the fine discussion here, solution is already mentioned...
Beta Was this translation helpful? Give feedback.
All reactions
-
Other solutions will display the whole route/page. No solution here allows to show only 1 component in isolation while still having everything TanStack Start/Router work, or I missed it, please link me to it, thanks!
Beta Was this translation helpful? Give feedback.
All reactions
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 1
-
We managed to get hooks working like so
//decorator
import type { Decorator } from '@storybook/react'
import {
createRootRoute,
createRouter,
RouterProvider,
createRoute,
createHashHistory,
} from '@tanstack/react-router'
export const RouterDecorator: Decorator = (Story) => {
const Root = createRootRoute({ component: () => <Story /> })
const HomeRoute = createRoute({
getParentRoute: () => Root,
path: '/',
})
const routeTree = Root.addChildren([HomeRoute, TestRoute])
const hashHistory = createHashHistory()
const router = createRouter({
routeTree,
history: hashHistory,
})
return <RouterProvider router={router} />
}
Then in our story which showcases a navigation blocker we can use the useNavigation hook to navigate({ to: '/' }) and trigger the Block component
Beta Was this translation helpful? Give feedback.
All reactions
-
I put together this solution (~draft) that addresses my main issue: displaying a full page with type-safe specific search or path parameters in Storybook using TanStack Router. I managed to get it working with this <WithRouter /> component. I haven't extensively tested it yet, but it seems to have some potential for others facing similar challenges.
import { createMemoryHistory, createRootRoute, createRoute, createRouter, Outlet, RouteIds, RouterProvider, NavigateOptions, RouteById, } from '@tanstack/react-router' import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' import { router } from '@shared/router' export type ExtractRouteOptions<TId extends RouteIds<(typeof router)['routeTree']>> = Omit< NavigateOptions<typeof router, string, TId>, 'to' | 'search' | 'params' > & { search?: Partial<RouteById<(typeof router)['routeTree'], TId>['types']['fullSearchSchema']> } & (RouteById<(typeof router)['routeTree'], TId>['types']['allParams'] extends Record< string, never > ? { params?: undefined } : { params: RouteById<(typeof router)['routeTree'], TId>['types']['allParams'] }) /** * A utility component to wrap children with a mocked router for testing purposes. * It allows you to specify the initial route, search parameters, and route parameters. * * @param children - The child components to be rendered within the router context. * @param routeId - The ID of the route to navigate to initially. * @param search - Optional search parameters for the route. * @param params - Optional route parameters. * * @returns A RouterProvider component that provides the mocked router context. */ export const WithRouter = < TRouter extends typeof router, const TId extends RouteIds<TRouter['routeTree']>, >({ children, routeId, search, params, }: React.PropsWithChildren< { routeId: TId } & ExtractRouteOptions<TId> >) => { const history = createMemoryHistory() const rootRoute = createRootRoute({ component: () => ( <> <Outlet /> <TanStackRouterDevtools /> </> ), }) const mockedRouter = createRouter({ history, routeTree: rootRoute.addChildren([ createRoute({ getParentRoute: () => rootRoute, path: routeId, component: () => children, }), ]), }) mockedRouter.navigate({ to: routeId.replace(/\/$/, ''), search, params } as any) return <RouterProvider router={mockedRouter} /> }
The usage looks like this:
import type { Meta, StoryObj } from '@storybook/react' import { WithRouter } from '@shared/lib/test' import { YourPageComponent } from './YourPageComponent' const meta: Meta<typeof YourPageComponent> = { component: YourPageComponent, } export default meta type Story = StoryObj<typeof YourPageComponent> export const Example: Story = { render: () => ( <WithRouter routeId="/your-route/$entityId" params={{ entityId: 'abc' }}> <TransformationPlansListingPage /> </WithRouter> ), }
Beta Was this translation helpful? Give feedback.