Setup Your Universal App with React Native, Expo Router, Tamagui, and Storybook
Tags: expo, reactnative, tamagui, storybook
Published: 2024-04-06 21:00:00

### Overview

In this guide, we'll walk you through setting up a universal app using React Native, Expo Router, Tamagui, and Storybook. This tutorial will provide step-by-step instructions to help you kickstart your universal app development journey.

### Prerequisites

1. **Node.js Latest Version:** Ensure you have Node.js installed on your system.
2. **For Android:**
   - Android Studio
   - Emulator
3. **For macOS:**
   - Xcode
   - Simulator

### Expo Router Setup

First, we will install an Expo project with file-based navigation using the Expo router.

Open your terminal from any preferred directory, then enter the command.

```tsx
npx create-expo-app@latest --template tabs@50
```

Now, you can start your project by running:

```tsx
npx expo start
```

This will create a minimal project with the Expo Router library already installed. This approach is recommended by Expo's official documentation, making setup easier compared to manual installation, which involves several steps. It's better to install it this way and then clean up the project. Next, we'll install Tamagui into our project.

### Tamagui Setup

> Step 01:

First we have to install `@tamagui/babel-plugin` package by this command

```tsx
npm install @tamagui/babel-plugin
```

> Step 02:

We have updated our `babel.config.js` file to include the optional `@tamagui/babel-plugin`. You can find the `babel.config.js` file in the root directory of your Expo project. Simply copy the code provided and paste it into your `babel.config.js` file.

```tsx
module.exports = function (api) {
  api.cache(true);
  return {
    presets: ["babel-preset-expo"],
    plugins: [
      [
        "@tamagui/babel-plugin",
        {
          components: ["tamagui"],
          config: "./tamagui.config.ts",
          logTimings: true,
          disableExtraction: process.env.NODE_ENV === "development",
        },
      ],
      // NOTE: this is only necessary if you are using reanimated for animations
      "react-native-reanimated/plugin",
    ],
  };
};
```

> Step 03:

Because we are setting up a universal app, we need to configure something specific for the web, especially for Tamagui. Therefore, we will make some adjustments to our `metro.config.js` file. You won't find this file in the root directory, so you'll need to create it manually with exactly the same name.

> Step 04:

Now install some dependency packages for web support using this command:

```tsx
npm install tamagui @tamagui/config @tamagui/metro-plugin
```

With this command, we are actually installing the Tamagui component library, Tamagui configuration, and the Metro plugin for web support.

> Step 05:

In your `metro.config.js` file that you have created previously, add the following code. Later, we will modify this file for Storybook, so remember that.

```tsx
// 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, {
  // [Web-only]: Enables CSS support in Metro.
  isCSSEnabled: true,
});
// add nice web support with optimizing compiler + CSS extraction
const { withTamagui } = require("@tamagui/metro-plugin");

module.exports = withTamagui(config, {
  components: ["tamagui"],
  config: "./tamagui.config.ts",
  outputCSS: "./tamagui-web.css",
});
```

> Step 06:

Now, create another file in your root directory called `tamagui.config.ts`. In this file, we will configure our Tamagui settings.

> Step 07:

Now add this following code into the file `tamagui.config.ts`

```tsx
import { config } from "@tamagui/config/v3";

import { createTamagui } from "tamagui";
export const tamaguiConfig = createTamagui(config);
export default tamaguiConfig;
export type Conf = typeof tamaguiConfig;

declare module "tamagui" {
  interface TamaguiCustomConfig extends Conf {}
}
```

So here, we are importing the Tamagui configuration v3 and exporting it as is, while also declaring the types. With this, our default Tamagui configuration setup is complete. Now, let’s utilize it.

> Step 08:

So, you will find a folder named `./app` where all of the Expo code will be available. Inside this folder, navigate to the `_layout.tsx` file and replace the code at the top with the following.

```tsx
import { Platform } from "react-native";

if (Platform.OS === "web") {
  import("../tamagui-web.css");
}

import {
  useFonts,
  Inter_400Regular,
  Inter_900Black,
} from "@expo-google-fonts/inter";
import {
  DarkTheme,
  DefaultTheme,
  ThemeProvider,
} from "@react-navigation/native";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";

import { useColorScheme } from "@/components/useColorScheme";
import { TamaguiProvider } from "tamagui";
import tamaguiConfig from "@/tamagui.config";

export {
  // Catch any errors thrown by the Layout component.
  ErrorBoundary,
} from "expo-router";

export const unstable_settings = {
  // Ensure that reloading on `/modal` keeps a back button present.
  initialRouteName: "(tabs)",
};

// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [loaded, error] = useFonts({
    Inter: Inter_400Regular,
    InterBold: Inter_900Black,
    SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
  });

  // Expo Router uses Error Boundaries to catch errors in the navigation tree.
  useEffect(() => {
    if (error) throw error;
  }, [error]);

  useEffect(() => {
    if (loaded) {
      SplashScreen.hideAsync();
    }
  }, [loaded]);

  if (!loaded) {
    return null;
  }

  return <RootLayoutNav />;
}

function RootLayoutNav() {
  const colorScheme = useColorScheme();

  return (
    <TamaguiProvider
      config={tamaguiConfig}
      defaultTheme={colorScheme as string}
    >
      <ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
        <Stack>
          <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
          <Stack.Screen name="modal" options={{ presentation: "modal" }} />
        </Stack>
      </ThemeProvider>
    </TamaguiProvider>
  );
}
```

Here we add the Tamagui provider to wrap our entire app with Tamagui using the default configuration. Later, we will modify the configuration. We import the CSS specifically for the web. You can see the code at the top of the file where we added a condition for importing the CSS. This means that the CSS will only be imported when the platform is web.

> Step 09:

We added the Inter font from `@expo-google-fonts/inter` into our `_layout.tsx` file using the `useFonts()` hook. This font is used in the CSS file we imported. So, installing this font is necessary. Install it with this command.

```tsx
npm install @expo-google-fonts/inter
```

You're done! However, if you run the project, you may encounter some errors and issues. I faced those and fixed them. The Tamagui documentation doesn't mention anything like that, so you'll have to do a little additional work.

> Step 10:

So, in your `_layout.tsx` file, you may encounter errors if you've installed ESLint as a VS Code extension. Then, your `import ("../tamagui-web.css")` code will give you an error like "don't have any type declaration." To get rid of this error, follow these steps:

1. Create a file called `declaration.d.ts` in your root directory
2. Then go to your `tsconfig.json` file from the root directory and replace your code with this one:

```tsx
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "module": "esnext",
    "target": "esnext",
    "esModuleInterop": true,
    "strict": true,
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}
```

So now you will be able to get rid of the warning.

> Step 11:

Replace your `package.json` file's script object with this.

```tsx
"scripts": {
    "start": "expo start -c",
    "android": "expo start --android -c",
    "ios": "expo start --ios -c",
    "web": "expo start --web -c",
    "test": "jest --watchAll"
  }
```

Here we are actually starting our project by cleaning the Expo cache, which is done by adding `-c` to our command.

> Step 12:

Run the app
for running all platforms: `npm start` and then press `w` for web, press `i` for ios and press `a` for Android. Make sure you have an Android emulator or code simulator

So if you see any error occurring after running the app. Then don’t worry.  Just close the terminal and restart the command like `npm start` or platform-specific we declare on the `package.json` file.

For seeing that tamagui working or not. Go to your `(tabs)` folder inside your `app` directory. And goto `index.tsx` file and replace the code with this one. Make sure you install `@tamagui/lucide-icons` by `npm install @tamagui/lucide-icons`

```tsx
import { Airplay } from "@tamagui/lucide-icons";
import { Button, Heading, View } from "tamagui";

export default function TabOneScreen() {
  return (
    <View flex={1} alignItems="center" justifyContent="center">
      <Heading size={"$10"} color={"violet"}>
        Tab One
      </Heading>

      <Button
        color={"violet"}
        backgroundColor={"black"}
        alignSelf="center"
        icon={Airplay}
        mt={10}
        size="$6"
      >
        Button
      </Button>
    </View>
  );
}
```

Here we are simply replacing the old `View`, `Text`, and `Button` components from React Native with their Tamagui counterparts. Now, you can leverage Tamagui along with React Native Expo Router. If you don't require Storybook, you can skip the next two sections.

### Storybook Setup

Now, we will set up Storybook in our Expo project and configure scripts to run the project with or without Storybook. Let's get started. Although there are templates available on their official GitHub repository, since we have already set up our project, we will need to add Storybook manually.

> Step 01:

Run init to setup your project with all the dependencies and configuration files:

```tsx
npx storybook@latest init
```

Now, you will notice a folder called `.storybook`. Do not delete it, as it contains all of our Storybook configurations. You can run Storybook simply by exporting it from your `_layout.tsx` file like this: `export { default } from './.storybook`'. However, we want to run it with a script to include Storybook in our project or exclude it. Therefore, we need to perform some manual configurations.

> Step 02:

Now Replace your `metro.config.js` file we created and modified for tamagui. Replace all of the code by this.

```tsx
const { getDefaultConfig } = require("expo/metro-config");
const { withTamagui } = require("@tamagui/metro-plugin");
/** @type {import('expo/metro-config').MetroConfig} */

// Storybook config
const path = require("path");
const { generate } = require("@storybook/react-native/scripts/generate");
generate({
  configPath: path.resolve(__dirname, "./.storybook"),
});

// Tamagui Config
const config = getDefaultConfig(__dirname, {
  isCSSEnabled: true,
});

// Storybook config
config.transformer.unstable_allowRequireContext = true;
config.resolver.sourceExts.push("mjs");

// Export the config with tamagui
module.exports = withTamagui(config, {
  components: ["tamagui"],
  config: "./tamagui.config.ts",
  outputCSS: "./tamagui-web.css",
});
```

> Step 03:

So we are going to use the `expo-constants` package to enable Storybook integration. Install the package by running the following command:

```tsx
npm install expo-constants
```

> Step 04:

Rename your `app.json` file to `app.config.js` and export the object as follows:

```tsx
module.exports = {
	...all the property from your app.json file [Means take the json file object and paste it here]
}
```

> Step 05:

Now add an `extra` object with a property to control the value for running the project with Storybook or without it. Here's it will be:

```tsx
module.exports = {
	...,
	"extra": {
     "storybookEnabled": false
	}
}
```

This configuration allows you to run the project with Storybook by setting the `storybookEnabled` variable to true. If it's not set or set to any other value, Storybook will not be included.

> Step 06:

So now how we can do that? We will write a little bit node js script to modify our `app.config.js` file. So let’s create e file called **`update-config.js`** and paste this code:

```tsx
const fs = require("fs");
const path = require("path");

const configFilePath = path.join(__dirname, "app.config.js");
const configContent = fs.readFileSync(configFilePath, "utf-8");

const newValue = process.argv[2] === "true"; // Get the desired value from the command-line argument

const updatedContent = configContent.replace(
  /storybookEnabled:\s*(true|false)/,
  `storybookEnabled: ${newValue}`
);

if (updatedContent !== configContent) {
  fs.writeFileSync(configFilePath, updatedContent);
  console.log(`app.config.js updated with storybookEnabled set to ${newValue}`);
} else {
  console.log(`app.config.js already has storybookEnabled set to ${newValue}`);
}
```

If you don’t understand this code, no worries, you can ignore it. Essentially, it reads the app.`config.js` file and replaces the `storybookEnabled` value with the provided value when we run the script. It will change it to `true` or `false` based on our command.

> Step 07:

Now time to change our `package.json` file script command to do everything by command not manually. So replace your script object with this one into your `package.json` file:

```tsx
{
	"start": "node update-config.js false && expo start -c",
    "android": "node update-config.js false && expo start --android -c",
    "ios": "node update-config.js false && expo start --ios -c",
    "web": "node update-config.js false && expo start --web -c",
    "test": "jest --watchAll",
    "storybook-generate": "sb-rn-get-stories",
    "storybook:start": "node update-config.js true && expo start -c",
    "storybook:android": "node update-config.js true && expo start --android -c",
    "storybook:ios": "node update-config.js true && expo start --ios -c",
    "storybook:web": "node update-config.js true && expo start --web -c"
}
```

Make sure you replace all the code inside the script object.

If you run it now, your project should run without giving any errors. However, we haven't iterated on the Storybook setup yet. Before that, let’s check if everything is working fine. Run the project using npm start. It may show some warnings like this:

```tsx
The following packages should be updated for best compatibility with the installed expo version:
  package name
  package name
  package name
Your project may not work correctly until you install the correct versions of the packages
```

That means you have to install those specific packages to run the application properly. Make sure you install all the packages mentioned with the exact version numbers specified. This warning may appear because your Expo project version is higher than the versions of the dependencies used by Storybook. After installing those packages, run the project again to check if it builds successfully.

Now, let’s add the code to swap between running our app with Storybook and without it.

> Step 08:

Go to the `./app` folder and create a file called `storybook.tsx`. This file will render the Storybook on a specific route. Our plan is that if we enable Storybook by setting the `storybookEnabled` flag to true and run the Storybook initialization command, then the route should render Storybook. If we don't enable Storybook, it will redirect to the home page. Inside this file, add the following code:

```tsx
import { Redirect } from "expo-router";
import StorybookUIRoot from "../.storybook";
import Constants from "expo-constants";

export const isStoryBookEnabled =
  Boolean(Constants?.expoConfig?.extra?.storybookEnabled) === true;

export default function StorybookScreen() {
  if (isStoryBookEnabled) {
    return <StorybookUIRoot />;
  }

  return <Redirect href="/" />;
}
```

So here, we are accessing the `storybookEnabled` variable from the extra object using expo-constants and checking whether it's set to true or not. If it's true, then we will render the Storybook. Now, we are almost done! To run the project with Storybook, use this command:` npm run storybook:start`. You may encounter an error similar to this:

```tsx
Unknown named module: "@storybook/global"
...bla bla
```

To resolve this error, you need to remove an addon from the Storybook plugin. Navigate to your `.storybook` folder and open the main.ts file. Replace the existing code with the following:

```tsx
import { StorybookConfig } from "@storybook/react-native";

const main: StorybookConfig = {
  stories: ["./stories/**/*.stories.?(ts|tsx|js|jsx)"],
  addons: ["@storybook/addon-ondevice-controls"],
};

export default main;
```

Here we just removed `'@storybook/addon-ondevice-actions’` this plugin from the storybook config. Now run the project again with `npm run storybook:start` And the error should gone!

Now let’s change a little bit on our`./(tabs)/index.tsx` file with following code:

```tsx
import { Airplay } from "@tamagui/lucide-icons";
import { Button, Heading, View } from "tamagui";
import { router } from "expo-router";
import { isStoryBookEnabled } from "../storybook";

export default function TabOneScreen() {
  return (
    <View flex={1} alignItems="center" justifyContent="center">
      <Heading size={"$10"} color={"violet"}>
        Tab One
      </Heading>

      <Button
        color={"violet"}
        backgroundColor={"black"}
        alignSelf="center"
        icon={Airplay}
        mt={10}
        size="$6"
      >
        Button
      </Button>

      {isStoryBookEnabled ? (
        <Button
          color={"violet"}
          onPress={() => {
            router.replace("/storybook");
          }}
          backgroundColor={"black"}
          alignSelf="center"
          icon={Airplay}
          mt={10}
          size="$6"
        >
          Go to Storybook
        </Button>
      ) : null}
    </View>
  );
}
```

Here we are checking whether our Storybook is enabled or not. Based on that, we have added a Storybook button.

Now you can test both scenarios:

With Storybook:

```
npm run storybook:start
```

Without Storybook:

```
npm start
```

So if you don’t want Storybook to show, simply run the command without Storybook enabled! For the production bundle, ensure that you remove Storybook from the bundler. You can refer to this guide to remove Storybook from the bundle [Check it out](https://dev.to/dannyhw/how-to-swap-between-react-native-storybook-and-your-app-p3o)

---

Feel free to reach out if you need more insights. Thank you for taking the time to read this article. Happy coding!

_Written with_ 🧡 _by Hasan_