Blog, COLDSURF

Using Storybook with Relay

Table of Contents

  1. Wrapping the RelayEnvironmentProvider with a Decorator in Storybook
  1. Mocking & Resolving Relay Queries Globally in Decorators
  1. Rendering with Actual Components
  1. Customizing Decorators (e.g., Navigation Decorator)

Introduction

While working on a feature that draws pages based on functionality, the typical workflow is as follows:
  1. Load the query to fetch initially required data when entering the page, based on schema.graphql.
  1. Use QueryLoader to load the query, and then use usePreloadedQuery to fetch data from the GraphQL server (or possibly the store depending on the situation).
  1. 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.
← Go home