Using Storybook with Relay
Table of Contents
- Wrapping the
RelayEnvironmentProvider
with a Decorator in Storybook
- Mocking & Resolving Relay Queries Globally in Decorators
- Rendering with Actual Components
- Customizing Decorators (e.g., Navigation Decorator)
Introduction
While working on a feature that draws pages based on functionality, the typical workflow is as follows:
- Load the query to fetch initially required data when entering the page, based on
schema.graphql
.
- Use
QueryLoader
to load the query, and then useusePreloadedQuery
to fetch data from the GraphQL server (or possibly the store depending on the situation).
- Once the necessary data is fetched from the server, render the UI components based on that data.
This is the common workflow for rendering feature-specific pages using Relay with GraphQL.
However, what happens if the server work is delayed or if the schema for the functionality isn't defined in
schema.graphql
?Instead of waiting for the server work, we might want to configure an environment that allows the client to proceed with work independently.
For REST APIs, tools like MSW (Mock Service Worker) can be used to mock data and render UI. However, we have a great framework available—Storybook!
Let's explore how we can use Storybook to render UI components without relying on the server.
1. Wrapping RelayEnvironmentProvider
with a Decorator in Storybook
Storybook provides a UI component rendering framework, and it includes a feature called decorators.
A decorator is essentially a component that wraps around the component being rendered in a Storybook story. The decorator can provide any necessary context or providers, without being directly rendered in the story file itself.
For more details, refer to the official Storybook decorator documentation:
Storybook Decorators
Decorators can be set globally, or individually per story, depending on the requirement.
Setting up a Global Decorator
Global decorators are configured in the
.storybook/preview.ts
file.const preview: Preview = { decorators: [ReactNavigationProviderDecorator, RelayEnvironmentProviderDecorator], }
As shown in the example above, global decorators can be set in the
preview
configuration. Once configured, these decorators will automatically wrap all the stories without needing to be manually set in each story file.Setting up Decorators in Each Story File
Here's an example of how to use decorators in individual story files:
import type { Meta, StoryObj } from '@storybook/react'; import { Button } from './Button'; const meta: Meta<typeof Button> = { component: Button, }; export default meta; type Story = StoryObj<typeof Button>; export const Primary: Story = { decorators: [ (Story) => ( <div style={{ margin: '3em' }}> <Story /> </div> ), ], };
In this example, decorators are provided as an array. Each decorator is a function that wraps the
Story
component.Applying RelayEnvironment Decorator
To apply a Relay environment for a GraphQL-based Storybook, you can create a custom Relay decorator as follows:
import { RelayEnvironmentProvider, createMockEnvironment } from 'react-relay'; const RelayEnvironmentProviderDecorator = (story: () => ReactNode): ReactElement => { const StoryComponent = (): ReactNode => story(); const [testEnvironment] = useState(() => createMockEnvironment()); useEffect(() => { const operations = testEnvironment.mock.getAllOperations(); operations.forEach((operation) => { testEnvironment.mock.resolve( operation, MockPayloadGenerator.generate(operation), ); }); }, [testEnvironment.mock]); return ( <RelayEnvironmentProvider environment={testEnvironment}> <StoryComponent /> </RelayEnvironmentProvider> ); };
This example provides a custom
RelayEnvironmentProviderDecorator
that sets up a mock Relay environment for Storybook. It mocks the necessary GraphQL operations and resolves them using MockPayloadGenerator
.To make the decorator more flexible, you can refactor it like this:
export const StorybookRelayEnvironmentProvider = ({ children, mockResolver, }: PropsWithChildren<{ mockResolver?: MockPayloadGenerator.MockResolvers; }>): ReactNode => { const [testEnvironment] = useState(() => createMockEnvironment()); useEffect(() => { const operations = testEnvironment.mock.getAllOperations(); operations.forEach((operation) => { testEnvironment.mock.resolve( operation, MockPayloadGenerator.generate(operation, mockResolver), ); }); }, [mockResolver, testEnvironment.mock]); return ( <RelayEnvironmentProvider environment={testEnvironment}> {children} </RelayEnvironmentProvider> ); }; const RelayEnvironmentProviderDecorator = (story: () => ReactNode): ReactElement => { const StoryComponent = (): ReactNode => story(); return ( <StorybookRelayEnvironmentProvider> <StoryComponent /> </StorybookRelayEnvironmentProvider> ); }; export default RelayEnvironmentProviderDecorator;
With this approach,
StorybookRelayEnvironmentProvider
can be customized with mockResolver
for each story, while RelayEnvironmentProviderDecorator
can be used globally.2. Mocking & Resolving Relay Queries Globally in Decorators
Now that we have a decorator that provides a Relay mock environment, let's dive deeper into how the mocking and resolving work.
export const StorybookRelayEnvironmentProvider = ({ children, mockResolver, }: PropsWithChildren<{ mockResolver?: MockPayloadGenerator.MockResolvers; }>): ReactNode => { const [testEnvironment] = useState(() => createMockEnvironment()); useEffect(() => { const operations = testEnvironment.mock.getAllOperations(); operations.forEach((operation) => { testEnvironment.mock.resolve( operation, MockPayloadGenerator.generate(operation, mockResolver), ); }); }, [mockResolver, testEnvironment.mock]); return ( <RelayEnvironmentProvider environment={testEnvironment}> {children} </RelayEnvironmentProvider> ); };
In the
StorybookRelayEnvironmentProvider
, we use useEffect
to mock GraphQL queries that are fetched by components using Relay. These mock operations are resolved using MockPayloadGenerator
, and if a mockResolver
is passed, it customizes the mock data.This setup ensures that when components use
usePreloadedQuery
, the data is mock-fetched instead of making real server requests.For more information on Relay testing, refer to the official Relay documentation: Testing Relay Components.
3. Rendering with Actual Components
Once the environment is set up, you can render your components using Storybook as you normally would.
import type { Meta, StoryObj } from '@storybook/react'; import SomeFeaturePage from './SomeFeaturePage'; const meta: Meta<typeof SomeFeaturePage> = { component: SomeFeaturePage, }; export default meta; type Story = StoryObj<typeof SomeFeaturePage>; export const Primary: Story = {};
This example assumes
SomeFeaturePage
is a component that uses Relay queries. You can customize the mock data using mockResolver
if needed.4. Customizing Decorators (e.g., Navigation Decorator)
If you're working in a React Native environment and need to mock navigation, you can create a Navigation decorator similar to the Relay decorator.
import { NavigationContainer } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; import type React from 'react'; import { ReactNode } from 'react'; const StoryBookStack = createStackNavigator(); export const StorybookNavigation = ({ screen, initialParams, }: { screen: React.ComponentType; initialParams: Partial<object | undefined>; }): ReactNode => { return ( <NavigationContainer independent={true}> <StoryBookStack.Navigator> <StoryBookStack.Screen name="MyStorybookScreen" component={screen} options={{ header: () => null }} initialParams={initialParams} /> </StoryBookStack.Navigator> </NavigationContainer> ); }; export const NavigationDecorator = (story: () => ReactNode): ReactNode => { const Screen = (): ReactNode => story(); return <StorybookNavigation screen={Screen} initialParams={{}} />; };
To use it with a story:
import type { Meta, StoryObj } from '@storybook/react'; import SomeFeaturePage from './SomeFeaturePage'; const meta: Meta<typeof SomeFeaturePage> = { component: SomeFeaturePage, }; export default meta; type Story = StoryObj<typeof SomeFeaturePage>; export const Primary: Story = { decorators: [ (Story) => { return ( <StorybookNavigation screen={Story} initialParams={{ chapter: 3, order: 3, }} /> ); }, ], };
For global decorators, you can similarly configure them in
.storybook/preview.ts
.With this approach, you can customize decorators for different functionalities, including navigation and Relay, to create a flexible environment for developing and testing UI components without relying on a server.