React Native - Expo, Nativewind, react-native-reusables starter with Eslint, Tsconfig

James ZhangJames Zhang
4 min read

This blog guides you to setup a universal mobile app repository using following tech stack:

  • React Native

  • Expo (Router)

  • Nativewind v4 (community mobile "port" of tailwind css)

  • react-native-reusables (community mobile "port" of ShadcnUI, Radix UI)

With some personal take in configuring Eslint, Tsconfig, Prettier etc.

Init repo

pnpx create-expo-app@latest --template tabs@50

Fix node_modules linking for pnpm specifically by adding a .npmrc at root:

# .npmrc
node-linker=hoisted

Then follow official tutorial from https://www.nativewind.dev/v4/getting-started/expo-router
Also setup Typescript https://www.nativewind.dev/v4/getting-started/typescript

tsconfig.json and absolute path imports

Modify app.json

// app.json
{
  "expo": {
    //...
    "experiments": {
      "typedRoutes": true,
      "tsconfigPaths": true
    }
  }
}

Modify tsconfig.json

{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"],
    },
    "jsx": "react-native",
    "types": ["nativewind/types"],
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "ES2022"],
    "allowJs": true,
    "skipLibCheck": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "Bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "incremental": true,
    "noUncheckedIndexedAccess": true,
  },
  "include": [
    "**/*.ts",
    "**/*.tsx",
    ".expo/types/**/*.ts",
    "expo-env.d.ts",
    "nativewind-env.d.ts",
  ],
}

Eslint

pnpm i -D eslint eslint-config-prettier eslint-config-universe eslint-plugin-prettier eslint-plugin-unused-imports prettier

.eslintignore

/.expo
node_modules

.eslintrc.json

{
  "extends": [
    "universe/native",
    "prettier"
  ],
  "plugins": ["unused-imports"],
  "rules": {
    "unused-imports/no-unused-imports": "error",
    "unused-imports/no-unused-vars": [
      "warn",
      {
        "vars": "all",
        "varsIgnorePattern": "^_",
        "args": "after-used",
        "argsIgnorePattern": "^_"
      }
    ]
  }
}

Prettier

.prettierrc

{
  "singleQuote": true,
  "arrowParens": "always",
  "trailingComma": "es5",
  "tabWidth": 2,
  "jsxSingleQuote": true,
  "bracketSameLine": true,
  "endOfLine": "auto"
}

.prettierignore please adjust accordingly as your project grows

/.expo
node_modules
ios
android
generated
dist
.expo
.expo-shared
web-build
graphql.schema.json

Example rewrite of tabOneScreen

import { Text, View } from 'react-native';

import EditScreenInfo from '../../components/EditScreenInfo';

export default function TabOneScreen() {
  return (
    <View className='flex-1 items-center justify-center bg-white dark:bg-black'>
      <Text className='text-xl font-bold'>Tab One</Text>
      <View className='my-[30] w-4/5 border-t-[1px] border-gray-300 dark:border-gray-50' />
      <EditScreenInfo path='app/(tabs)/index.tsx' />
    </View>
  );
}

react-native-reusables

A ShadcnUI like library for React Native. Copy necessary codes from the repo (https://github.com/mrzachnugent/react-native-reusables) and install expo dev client if necessary (https://docs.expo.dev/build/introduction/).

Theming

This is same to tailwind css theming https://tailwindcss.com/docs/theme

To create colors, I recommend https://uicolors.app/create

Button component example

This gives utilities to create customized button variants. And we can customize the button from every lower level detail.

@/components/ui/button.tsx

import { cva, type VariantProps } from 'class-variance-authority';
import { useColorScheme } from 'nativewind';
import * as React from 'react';
import { Platform, Pressable, Text, View } from 'react-native';

import * as Slot from '@/lib/rn-primitives/slot/slot-native';
import { cn, isTextChildren } from '@/lib/utils';

const buttonVariants = cva(
  'flex-row items-center justify-center rounded-md web:ring-offset-background web:transition-colors web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2',
  {
    variants: {
      variant: {
        default: 'bg-primary',
        destructive: 'bg-destructive',
        outline: 'border border-input bg-background',
        secondary: 'bg-secondary',
        ghost: '',
        link: '',
      },
      size: {
        default: 'px-4 py-2 native:px-6 native:py-3.5',
        sm: 'px-3 py-1 px-3 native:py-2',
        lg: 'px-8 py-1.5 px-8 native:py-4',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

const buttonTextVariants = cva('font-medium', {
  variants: {
    variant: {
      default: 'text-primary-foreground',
      destructive: 'text-destructive-foreground',
      outline: 'text-foreground',
      secondary: 'text-secondary-foreground',
      ghost: 'text-foreground',
      link: 'text-primary underline',
    },
    size: {
      default: 'text-sm native:text-xl',
      sm: 'text-xs native:text-lg',
      lg: 'text-base native:text-2xl',
    },
  },
  defaultVariants: {
    variant: 'default',
    size: 'default',
  },
});

const rippleColor = (isThemeDark: boolean) => {
  const secondary = isThemeDark ? 'hsl(240 4% 16%)' : 'hsl(240 5% 96%)';
  return {
    default: isThemeDark ? '#d4d4d8' : '#3f3f46',
    destructive: isThemeDark ? '#b91c1c' : '#f87171',
    outline: secondary,
    secondary: isThemeDark ? '#3f3f46' : '#e4e4e7',
    ghost: secondary,
    link: secondary,
  };
};

const Button = React.forwardRef<
  React.ElementRef<typeof Pressable>,
  React.ComponentPropsWithoutRef<typeof Pressable> &
    VariantProps<typeof buttonVariants> & {
      textClass?: string;
      androidRootClass?: string;
    }
>(
  (
    {
      className,
      textClass,
      variant = 'default',
      size,
      children,
      androidRootClass,
      disabled,
      ...props
    },
    ref
  ) => {
    const { colorScheme } = useColorScheme();
    const Root = Platform.OS === 'android' ? View : Slot.Pressable;
    return (
      <Root
        className={cn(
          Platform.OS === 'android' && 'flex-row rounded-md overflow-hidden',
          Platform.OS === 'android' && androidRootClass
        )}>
        <Pressable
          className={cn(
            buttonVariants({
              variant,
              size,
              className: cn(
                className,
                disabled && 'opacity-50 web:cursor-default'
              ),
            })
          )}
          ref={ref}
          android_ripple={{
            color: rippleColor(colorScheme === 'dark')[variant as 'default'],
            borderless: false,
          }}
          disabled={disabled}
          {...props}>
          {isTextChildren(children)
            ? ({ pressed, hovered }) => (
                <Text
                  className={cn(
                    hovered && 'opacity-90',
                    pressed && 'opacity-70',
                    buttonTextVariants({ variant, size, className: textClass }),
                    disabled && 'opacity-100'
                  )}>
                  {children as string | string[]}
                </Text>
              )
            : children}
        </Pressable>
      </Root>
    );
  }
);
Button.displayName = 'Button';

export { Button, buttonTextVariants, buttonVariants };
0
Subscribe to my newsletter

Read articles from James Zhang directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

James Zhang
James Zhang

Founding Engineer at Bazar (General Catalyst backed), Ex Meta Ads MLE, Ex Activision Blizzard Intern