Building beautiful components faster with Storybook 9 and Expo

DevelopmentReact Native12 minutes read

Daniel Williams

Daniel Williams

Guest Author

Learn how to set up Storybook 9 in your Expo app to build, test, and share React Native UI components faster and with less friction.

Building beautiful components faster with Storybook 9 and Expo

This is a guest blog post from Daniel Williams - a frontend developer and open-source maintainer with over 10 years of experience in React and React Native.

...

Picture this: You’re building a complex screen deep in your React Native app with a variety of success and error states. Whenever one of your changes triggers a full reload, you need to login to your app and get back to the state you were working on.

The first few times this happens, you shrug it off. But this workflow gets tiresome really fast!

What if, instead, you had a catalog of each of your UI states? You could not only navigate directly to that screen to pick up where you left off before the change, but you could also share those screens with your teammates for easy review. You could even turn those UI states into automated tests that run quickly in CI without all the flake of an end-to-end test.

The workflow I’m describing here is exactly what Storybook for React Native is for. In this post, I’ll guide you how to get it set up in your Expo app so you can get the most out of it. The post is based on the recently released Storybook 9, which offers the best React Native support yet.

Setup Storybook 9 in an Expo app

For those new to Storybook, it's a development environment that lets you build, test, and document UI components in isolation. Think of it as a workshop where you can craft each component independently, without the complexity of your full application. This component-first workflow enables visual testing, serves as living documentation, and helps teams maintain design consistency across large codebases.

Before we begin, make sure you have:

  • An existing Expo app using Expo Router (SDK 50 or later recommended)
    • Or create a new one to follow along with npx create-expo-app
    • if you make a new expo app, run npm run reset-project to get a clean project
  • Node.js 20+ and your preferred package manager (bun, npm, yarn, or pnpm)
  • A device or simulator for testing

If you want to check the finished version of the storybook in this guide you can find the example repo here.

Installing Storybook

The easiest way to add Storybook to your existing Expo Router project is the create command

Terminal
npm create storybook@latest

When prompted, choose recommended and then React Native. This creates a .rnstorybook configuration folder and installs the necessary dependencies, including @storybook/react-native.

Configure Metro bundler

We'll need some Metro configuration to work with Storybook. First, customize your Metro config:

Code
npx expo@latest customize metro.config.js

Then update your metro.config.js to include Storybook support:

Code
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
const withStorybook = require("@storybook/react-native/metro/withStorybook");
/** withStorybook Adds the config that storybook uses */
module.exports = withStorybook(config);

Create a Storybook route

In Expo Router, create a new route for Storybook by adding app/storybook.tsx:

Code
export { default } from '../.rnstorybook';

This creates a /storybook route that you can navigate to within your app.

Configure your storybook route to hide the header and make storybook only accessible in dev (or based on your own logic). To do this edit your layout file app/_layout.tsx like so:

Code
// app/_layout.tsx
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Protected guard={__DEV__}>
<Stack.Screen name="storybook" />
</Stack.Protected>
</Stack>
);
}

Using Stack.Protected here makes it so the screen will only be accessible in dev, you can expand on this by using an environment variable or other logic to change how this works.

Running Storybook

The only thing left to do is run your app.

Terminal
npm run start

Now to access storybook by add a link to the /storybook route in your app. For now lets add it in the index app/index.tsx .

Code
import { Link } from "expo-router";
import { View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Link href="/storybook">Open Storybook</Link>
</View>
);
}

When you open the storybook screen you should see something like this:

Setting up Storybook

Write stories for your components

In Storybook a story is an example of your components in a specific UI state. For example I can make Loading and Disabled stories that shoes my button in each in those states.

Lets build out an input component to show off the different Storybook features.

In a moment we’ll create a components folder for our new components and stories. In order for storybook to pick up stories in that folder we need to add the components directory to our stories regex in .rnstorybook/main.ts:

Code
// .rnstorybook/main.ts
import { StorybookConfig } from "@storybook/react-native";
const main: StorybookConfig = {
stories: [
"./stories/**/*.stories.?(ts|tsx|js|jsx)",
// the paths are relative to the main.ts file itself
"../components/**/*.stories.?(ts|tsx|js|jsx)", // <--- Add this
],
addons: [
"@storybook/addon-ondevice-controls",
"@storybook/addon-ondevice-actions",
],
};
export default main;

🚨 Important Note:

Make sure to restart metro after making changes to your stories regex. This will trigger generation of the storybook.requires.ts file which enables Storybook to watch the new directories you included.

Lets create a simple input component with a few states. Add a input.tsx file in the components directory (create the directory if you haven’t already):

Code
// components/input.tsx
import { useState } from "react";
import { Text, TextInput, View, type TextInputProps } from "react-native";
type InputProps = TextInputProps & {
label?: string;
error?: string;
disabled?: boolean;
};
const getBorderColor = (isFocused: boolean, error?: string) => {
if (error) {
return "#FF3B30";
}
return isFocused ? "#007AFF" : "#D1D1D6";
};
export const Input = ({ disabled, label, error, ...props }: InputProps) => {
const [isFocused, setIsFocused] = useState(false);
const borderColor = getBorderColor(isFocused, error);
return (
<View style={{ gap: 4 }}>
<Text id="input-label" style={{ fontSize: 14, color: "#3C3C43" }}>
{label}
</Text>
<TextInput
aria-labelledby="input-label"
aria-disabled={disabled}
style={{
borderWidth: 1,
padding: 12,
borderRadius: 8,
borderColor,
backgroundColor: disabled ? "#F5F5F5" : "transparent",
}}
editable={!disabled}
onFocus={() => {
setIsFocused(true);
}}
onBlur={() => {
setIsFocused(false);
}}
{...props}
/>
{error && <Text style={{ fontSize: 12, color: "#FF3B30" }}>{error}</Text>}
</View>
);
};

Add a input.stories.tsx file in the components directory like this:

Code
// components/input.stories.tsx
import { Meta, StoryObj } from "@storybook/react-native";
import { Input } from "./input";
const meta = {
component: Input,
} satisfies Meta<typeof Input>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Basic: Story = {
args: {
label: "First Name",
placeholder: "John",
},
};

Now you’ve got your basic story and you’ll see the input in your storybook with the “First Name” label and “John” placeholder.

The args of a story set the default props passed to the component when the story renders, you can then adjust the state in realtime using the controls panel.

Now we can add a few more states to our stories file by adding the Error and Disabled stories like so:

Code
// components/input.stories.tsx
// ... rest of the story file 👆
export const Error: Story = {
args: {
label: "Email",
error: "Email is required",
disabled: false,
placeholder: "example@example.com",
},
};
export const Disabled: Story = {
args: {
label: "Disabled",
error: "",
disabled: true,
placeholder: "Disabled",
},
};

Now you’ll see some new states show up in the menu and you can quickly go between them.

You might notice that our input component is flush with the sides of the ui and it might be nicer if we could add some kind of wrapper to add padding around it. This is exactly the kind of thing decorators can be used for.

Lets edit the meta for our stories file to add a decorator:

Code
import { View } from "react-native"; // add this
// components/input.stories.tsx
const meta = {
component: Input,
decorators: [
// Here we're adding a container with 16px padding.
// add this 👇
(Story) => (
<View style={{ padding: 16 }}>
<Story />
</View>
),
],
} satisfies Meta<typeof Input>;
// Rest of your stories file here...

You should now see that there’s a bit more space around our component.

You can also use decorators for things like theme, mocking, or application state providers. You can also add decorators on the Story level for individual stories or globally in .rnstorybook/preview.tsx .

Code
const meta = {
component: Input,
decorators: [
// You can wrap your stories here with anything you like
// Here I added an example ThemeProvider that could come from a UI library
(Story) => (
<ThemeProvider>
<View style={{ padding: 16 }}>
<Story/>
</View>
</ThemeProvider>
),
],
} satisfies Meta<typeof Input>;

For more on writing stories please see the official docs here.

Share your Storybook

Storybook is great for developing components in isolation, but another superpower is the ability to share your components with your teammates.

Before moving forward lets setup a method for enabling/disabling storybook.

Edit the _layout file to show storybook only when EXPO_PUBLIC_ENVIRONMENT is set to storybook.

Code
// app/_layout.tsx
export default function RootLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Protected
guard={process.env.EXPO_PUBLIC_ENVIRONMENT === "storybook"}
>
<Stack.Screen name="storybook" />
</Stack.Protected>
</Stack>
);
}

You can then add a script to package.json to run with the environment set to storybook

Code
"storybook": "EXPO_PUBLIC_ENVIRONMENT='storybook' expo start",

Now when you want to develop you components in storybook you can run npm run storybook and then to develop your app as normal run npm run start.

Note that we didn’t put anything else in our app and we won’t focus on that for now, but you could now hide your link to storybook if the environment is not storybook.

Sharing for iOS with TestFlight

You’ll need an Apple developer account to be able to use TestFlight, this gives you the ability to publish to the app store and use TestFlight for internal and public testing. You’ll also need an expo account to use eas. Create your expo account here: https://expo.dev/signup.

Once you have that setup we can move forward.

Using TestFlight enables you to share your app easily with beta testers or team members, EAS comes with some nice tools to automate its use.

Start by installing the EAS cli with this command:

Code
npm install -g eas-cli

Now lets setup our EAS project and configure for builds and eas updates. Run each of these commands and follow the prompts.

Code
eas login
eas init
eas build:configure -p all
eas update:configure

Now you should have an eas.json file setup for building on EAS and for EAS updates.

Next lets update that eas.json to make a storybook build with a different name and identifier so it will have its own TestFlight distribution separate from your main app.

Code
{
"build": {
// other build types...
// add storybook build type here
"storybook": {
"distribution": "store",
"channel": "storybook",
"autoIncrement": true,
"ios": {
"simulator": false
},
"env": {
"EXPO_PUBLIC_ENVIRONMENT": "storybook",
}
}
},
"submit": {
// other submit types...
// add storybook submit type here
"storybook": {
"ios": {
"bundleIdentifier": "com.example.myapp.storybook",
"appName": "My App Storybook"
}
}
}
}

Add a new file called app.config.ts which will extend our existing app.json config and add some dynamic values. We need this because we want to change our bundle identifier based on which build we are using.

Code
import { type ExpoConfig, ConfigContext } from 'expo/config';
function getBundleIdentifier() {
const isStorybook = process.env.EXPO_PUBLIC_ENVIRONMENT === 'storybook';
if (isStorybook) {
return 'com.example.myapp.storybook';
}
return 'com.example.myapp';
}
function config({ config }: ConfigContext): Partial<ExpoConfig> {
return {
// this is the config from app.json
...config,
ios: {
...config.ios,
bundleIdentifier: getBundleIdentifier(),
infoPlist: {
...config.ios?.infoPlist,
ITSAppUsesNonExemptEncryption: false,
},
},
};
}
export default config;

Now to build our app and submit to app center:

Code
eas build -p ios --submit --profile storybook

Follow the prompts and if all goes well then you’ll then get an email saying that you were invited to TestFlight, after accepting this you should be able to access the app.

You can now to publish to test flight your app with Storybook and your production app in a convenient way.

Publish to TestFlight

Now to share with other team members you can add them to your testing team for test flight by visiting app store connect.

Android

For android you can make either an internal testing build available on the play store or you can create an internal distribution with an apk buildType:

Code
{
"build": {
// other build types...
"storybook": {
"distribution": "store",
"channel": "storybook",
"autoIncrement": true,
"ios": {
"simulator": false
},
"env": {
"EXPO_PUBLIC_ENVIRONMENT": "storybook"
}
},
// 👇 for internal sharing of apk builds
"storybook-internal": {
"extends": "storybook",
"distribution": "internal",
"android": { "buildType": "apk" }
}
},
}

Then update your app.config.ts

Code
function config({ config }: ConfigContext): Partial<ExpoConfig> {
return {
// this is the config from app.json
...config,
// rest of the config...
android: {
...config.android,
package: getBundleIdentifier(),
},
};
}

Now to create an internal build run:

Code
eas build -p android --profile storybook-internal

When you open up the build in eas you’ll see a button to install that will give you a QR code and a link that you can use to download the apk directly.

Publishing on the Web

The React Native Storybook UI is compatible with React Native Web so if you are supporting the web with your components then you’ll be able to use expo export and eas deploy to deploy your stories on the web.

First build your app for web:

Code
EXPO_PUBLIC_ENVIRONMENT=storybook bun expo export --platform web

Then deploy:

Code
eas deploy

Heres one I deployed earlier: https://sharestorybook--qclgxuc645.expo.app/

Turn your stories into tests

Another benefit of documenting your components with stories is that you can easily reuse those UI states in automated tests.

For example, to test our Input story above, we can “compose” the story, which turns it into a React component that can be used with Jest and Testing Library.

Unit testing is supported via portable stories, this gives you the ability to run jest and testing library style tests whilst reusing the stories you create for your components.

To get started install the dependencies:

Code
npx expo install --dev jest-expo jest @types/jest @testing-library/react-native pretty-format@29.7.0

Now add to package.json a script to run your tests and the jest config:

Code
{
"scripts": {
"test": "jest --watchAll",
},
"jest": {
"preset": "jest-expo"
},
}

We can add a input.test.tsx to try it out:

Code
// Button.test.tsx
import { composeStories } from "@storybook/react";
import { render, screen } from "@testing-library/react-native";
import * as stories from "./input.stories";
// Every story is returned as a composed component
const { Basic } = composeStories(stories);
test("renders primary button with default args", () => {
render(<Basic />);
const label = screen.getByText("First Name");
expect(label).toBeTruthy();
});

Now if you run npm run test here’s what that looks like in the CLI:

If we update our code in a way that breaks the Input render, these tests would fail our CI:

We’re actively working on bringing even more powerful tests to React Native Storybook. For updates, follow me on X or Bluesky to get notified of the latest improvements.

Storybook 9 is out!

This post is written for Storybook 9, which was recently released in June. It brings several exciting improvements specifically for React Native developers:

  • Simplified configuration: The new folder structure makes it easier to run both web and React Native Storybook side by side.
  • Lite mode UI: A lighter version of the UI written without the extra dependencies that allows Storybook to run on TV or MacOS.
  • Better controls: Enhanced conditional args and better compatibility with web
  • 48% leaner core: Storybook's core packages are dramatically smaller with a flatter dependency structure.

Try Storybook 9 today

As you can see Storybook offers you tools to iterate on your design system with ease and without the manual steps of navigating through your app. Combined with expo you can share TestFlight builds of your design system with your designers and other stakeholders to get early feedback.

To quickly try out storybook you can try this template:

Code
npx create-expo-app --template expo-template-storybook@latest AwesomeStorybook54

For more on the changes in Storybook 9 check out this link: https://storybook.js.org/blog/storybook-9/

And take a look at the documentation site for React Native to learn more about this integration: https://storybookjs.github.io/react-native/docs/intro/getting-started/

Follow me on X or Bluesky (made with Expo!).

Storybook
Expo Router
React Native UI

Dive in, and create your first Expo project

Learn more