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

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

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 };