Back in 2023 I wrote a post with a very similar title
It's been a while since I first wrote the original post on this topic, and I was quite surprised with how many people actually found that useful.
That original post has now become quite outdated though and I want to revisit this topic to show how much easier it is to do this now. Also how you can ensure that nothing leaks into your production bundle.
Whats changed?
A few different things happened that make the process simpler today.
Built in support for envs on Expo
At the time of the original post there wasn't a built in solution for environment variables on expo and we made use of expo constants. This works but requires a few more steps.
https://docs.expo.dev/guides/environment-variables
withStorybook metro helper function
In newer versions of React Native Storybook we now include a metro helper that applies some metro configuration and handles generating your story imports.
This also made it possible for us to introduce improvements to how we you enable/disable storybook.
Metro improved
We've also got some newer configuration options in metro such as resolver.resolveRequest that allow you to override some resolver functionality with your own logic. For example for storybook files we make sure that enablePackageExports is enabled.
https://docs.expo.dev/versions/latest/config/metro/#custom-resolving
Lets get into it
I'll split this up into expo and community cli guides, scroll down if you aren't using expo.
The Expo guide
I'm going to assume you already have a project setup to keep things simple. If you want to see how to get setup with expo router then check out my blog post on the expo blog.
I have also setup a blank typescript example app here that just has storybook setup. In the pull requests for this repo I followed the same guide so you can inspect the full code there.
Lets assume you have a project like this
.rnstorybook/
src/index.tsx
app.tsx
metro.config.js
You probably have a metro config like this:
// metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const {
withStorybook,
} = require("@storybook/react-native/metro/withStorybook");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
module.exports = withStorybook(config);
Right now you're just commenting out storybook and adding it back in when you need it.
// import StorybookUI from './.rnstorybook';
import { AppRoot } from './src';
export default function App() {
// return <StorybookUI/>
return <AppRoot />;
}
Theres a better way!
Expo now will inject environment variables into process.env like you might be used to from server or web frameworks. We can use this to simplify our life.
How To actually do it
First lets add a storybook script to our package.json
{
"scripts": {
"storybook": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start",
}
}
This is just the start command but with the EXPO_PUBLIC_STORYBOOK_ENABLED environment variable set to true.
Now lets go to our app and add a simple bit of logic.
// App.tsx
import StorybookUI from './.rnstorybook';
import { AppRoot } from './src';
const isStorybook = process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === 'true';
export default function App() {
return isStorybook ? <StorybookUI/> : <AppRoot />;
}
Now when ever you run yarn storybook you see storybook and whenever you run yarn start you see your app.
But my production bundle!
You might notice that we're importing storybook even when not using it and that will cause metro to include all the dependencies in the bundle.
This is where we bring in the new metro options.
Update your metro config to pass some options to withStorybook.
// metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const {
withStorybook,
} = require("@storybook/react-native/metro/withStorybook");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
module.exports = withStorybook(config, {
enabled: process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === "true", // <-
});
The enabled option when set to false in version 10 will actually strip storybook out of your bundle. I will explain a little bit how that works at the end in case you are interested.
Thats it!
Nothing else is needed, and from here you can expand on this idea and come up with more developer friendly ways to hide/show storybook. One example is to create a dev menu option and use it to toggle storybook in combination with the environment variable.
Expo router
With expo router I recommend putting Storybook in its own route and using a protected route to disable it when Storybook is not enabled.
// app/_layout.tsx
export default function RootLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Protected
guard={process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === "true"}
>
<Stack.Screen name="storybook" />
</Stack.Protected>
</Stack>
);
}
Community Cli
If you are using the community cli or rock you can follow this guide.
I'm going to assume you already have a project setup to keep things simple. You can find an example project here
Lets assume you have a project like this
.rnstorybook/
src/index.tsx
app.tsx
metro.config.js
You probably have a metro config like this:
// metro.config.js
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const {
withStorybook,
} = require("@storybook/react-native/metro/withStorybook");
/**
* Metro configuration
* https://reactnative.dev/docs/metro
*
* @type {import('@react-native/metro-config').MetroConfig}
*/
const config = {};
const finalConfig = mergeConfig(getDefaultConfig(__dirname), config);
module.exports = withStorybook(finalConfig);
Right now you're just commenting out storybook and adding it back in when you need it.
// import StorybookUI from './.rnstorybook';
import { AppRoot } from './src';
export default function App() {
// return <StorybookUI/>
return <AppRoot />;
}
Theres a better way!
By using the babel plugin transform-inline-environment-variables we can get environment variables into our app bundle and start use that to make things easier.
How To actually do it
Add the babel plugin
//babel.config.js
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: [
'transform-inline-environment-variables', // <--
],
};
Lets add a storybook script to our package.json
{
"scripts": {
"storybook": "STORYBOOK_ENABLED=true react-native start",
}
}
This is just the start command but with the STORYBOOK_ENABLED environment variable set to true.
Now lets go to our app and add a simple bit of logic.
// App.tsx
import StorybookUI from './.rnstorybook';
import { AppRoot } from './src';
const isStorybook = process.env.STORYBOOK_ENABLED === 'true';
export default function App() {
return isStorybook ? <StorybookUI/> : <AppRoot />;
}
Now when ever you run yarn storybook you see storybook and whenever you run yarn start you see your app.
But my production bundle!
You might notice that we're importing storybook even when not using it and that will cause metro to include all the dependencies in the bundle.
This is where we bring in the new metro options.
Update your metro config to pass some options to withStorybook.
// metro.config.js
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const {
withStorybook,
} = require("@storybook/react-native/metro/withStorybook");
/**
* Metro configuration
* https://reactnative.dev/docs/metro
*
* @type {import('@react-native/metro-config').MetroConfig}
*/
const config = {};
const finalConfig = mergeConfig(getDefaultConfig(__dirname), config);
module.exports = withStorybook(finalConfig, {
enabled: process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === "true", // <-
});
The enabled option when set to false in version 10 will actually strip storybook out of your bundle.
Thats it!
Nothing else is needed, and from here you can expand on this idea and come up with more developer friendly ways to hide/show storybook. One example is to create a dev menu option and use it to toggle storybook in combination with the environment variable.
How does this work
The withStorybook wrapper makes use of custom resolvers like I've mentioned earlier in this post.
First let me expand a bit on what I mean by that.
mock a module using metro resolvers
If you look at the expo docs about metro you'll find this example that I think illustrates pretty well how you can use resolvers to mock out a module.
const { getDefaultConfig } = require('expo/metro-config');
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (platform === 'web' && moduleName === 'lodash') {
return {
type: 'empty',
};
}
// Ensure you call the default resolver.
return context.resolveRequest(context, moduleName, platform);
};
module.exports = config;
By return type: 'empty' metro essentially just ignore that entire codepath and you won't find it in your bundle anymore.
However this isn't limited to just 'empty', you can also swap out the file thats used.
// ...
return {
filePath: 'path-to-file-here',
type: 'sourceFile',
};
The implementation in Storybook
In Storybook we combine both of these to make this work, we return empty for anything in your .rnstorybook folder and replace .rnstorybook/index.tsx with a file that imports nothing and exports a component warning you that storybook is disabled.
if (!enabled) {
return {
...config,
resolver: {
...config.resolver,
resolveRequest: (context: any, moduleName: string, platform: string | null) => {
const resolveFunction: ResolveRequestFunction = config?.resolver?.resolveRequest
? config.resolver.resolveRequest
: context.resolveRequest;
const resolved = resolveFunction(context, moduleName, platform);
if (resolved.filePath?.includes?.(`${configPath}/index.tsx`)) {
return {
filePath: path.resolve(__dirname, '../stub.js'),
type: 'sourceFile',
};
}
if (resolved.filePath?.includes?.(configPath)) {
return { type: 'empty' };
}
return resolved;
},
},
};
}
The reason we specifically stub out the index file is that otherwise if you have this code and StorybookUI isn't a component then its going to crash.
export default function App() {
return isStorybook ? <StorybookUI/> : <AppRoot />;
}
By returning an empty component then this syntax still works and the original code is still removed from the bundle.
The End
I believe that covers everything, let me know if you have any questions or if I missed anything.
As always you can reach out to me on twitter:
Also you can sponsor me on github if you like my work:
https://github.com/sponsors/dannyhw/

Top comments (0)