Linting Basics
10 examples to get you started with Linting & Formatting - 7 basic and 3 intermediate.
Search across all documentation pages
10 examples to get you started with Linting & Formatting - 7 basic and 3 intermediate.
Start from an Expo SDK 57 project. The default@sdk-57 template already includes TypeScript and the recommended scripts - you only need to add ESLint once.
npx create-expo-app@latest MyApp --template default@sdk-57
cd MyApp
npx expo lintnpx expo lint installs eslint, eslint-config-expo, and writes eslint.config.js at the project root. Confirm the SDK pin before customizing rules:
{
"dependencies": {
"expo": "~57.0.4",
"react": "19.2.3",
"react-native": "0.86.0"
},
"devDependencies": {
"eslint": "^9.0.0",
"eslint-config-expo": "~57.0.0"
}
}Tooling: These examples target Expo SDK 57 (
expo~57.0.4), React Native 0.86.0, and React 19.2.3. Install the ESLint VS Code extension so violations surface while you edit.
npx expo lintThe Expo CLI is the supported path to a working flat config - it pins compatible eslint and eslint-config-expo versions for your SDK.
# First run: installs devDependencies and creates eslint.config.js
npx expo lint
# Subsequent runs: lint the whole project
npx expo lint
# Auto-fix safe violations
npx expo lint --fixnpx expo lint reads the lint script from package.json and forwards flags to ESLinteslint.config.js and lockfile changes together.eslintrc.* files before adopting flat config - ESLint 9 prefers flat config but falls back to legacy when both existRelated: ../project-setup/project-setup-basics.md - SDK pin and first-run verification checklist
eslint-config-expo Flat ConfigSDK 53+ projects use ESLint flat config. The scaffolded file extends Expo's preset - React, hooks, TypeScript, and import rules are already wired.
// eslint.config.js
const { defineConfig } = require("eslint/config");
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
},
]);eslint-config-expo/flat exports a defineConfig array - spread or reference it as the first block so Expo defaults load before your overrides__DEV__, fetch, Hermes-friendly APIs) so screen files lint without /* global */ comments@typescript-eslint rules apply to .ts and .tsx files when TypeScript is present in the projectexpoConfig so you override rather than replace the presetRelated: Prettier & Import Sorting - layer Prettier without fighting ESLint formatting rules
npm run lint ScriptWire linting into package.json so teammates, pre-commit hooks, and CI all invoke the same command.
{
"scripts": {
"start": "expo start",
"lint": "expo lint",
"lint:fix": "expo lint --fix",
"typecheck": "tsc --noEmit"
}
}npm run lint
npm run lint:fix"lint": "expo lint" is what npx expo lint executes under the hood - prefer the npm script in docs and CI YAML so the entry point stays obviouslint:fix for local cleanup; avoid auto-fix in CI unless your pipeline is idempotent and fasttypecheck with lint - ESLint catches patterns TypeScript ignores (hooks deps, import order) and vice versaRelated: CI Quality Gates - fail builds on lint and typecheck together
Start with a small rules block after the Expo preset. These two adjustments cover most new Expo teams without turning lint into a rewrite project.
// eslint.config.js
const { defineConfig } = require("eslint/config");
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"react/react-in-jsx-scope": "off",
},
},
{
ignores: ["dist/*", ".expo/*"],
},
]);// app/index.tsx - `_event` is intentionally unused; lint passes
import { Pressable, Text } from "react-native";
export default function Home() {
return (
<Pressable onPress={(_event) => console.warn("pressed")}>
<Text>Tap me</Text>
</Pressable>
);
}argsIgnorePattern: "^_" is the standard escape hatch for event handlers you must accept but do not readreact/react-in-jsx-scope: off is correct for React 17+ JSX transform - Expo's Babel preset already enables it"warn" temporarily when rolling out a rule across a large brownfield codebaseRelated: TypeScript Strict Mode - align compiler strictness with lint rules over time
Generated and vendored paths should never block a merge. In flat config, put ignores in a dedicated object with ignores as the only key.
// eslint.config.js
const { defineConfig, globalIgnores } = require("eslint/config");
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
globalIgnores([
"node_modules/**",
".expo/**",
"dist/**",
"web-build/**",
"ios/**",
"android/**",
]),
expoConfig,
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
]);globalIgnores (or a standalone { ignores: [...] } object) skips files entirely - faster runs and fewer false positives.expo/ - local dev server state, not sourceios/ and android/ in managed workflow until you intentionally commit native projects (CNG / bare)ignores with rules in the same object creates a file-scoped filter, not a global skip - keep ignores isolatedRelated: Dead Code & Dependency Analysis - find unused files lint never touches
--fixTarget a feature folder while iterating so feedback stays fast on large apps.
# Lint only the app router tree
npx expo lint app
# Lint a shared package in a monorepo
npx expo lint packages/ui/src
# Fix auto-fixable issues in one screen
npx expo lint app/(tabs)/index.tsx --fix// app/(tabs)/index.tsx - missing hook dep flagged before you open a PR
import { useEffect, useState } from "react";
import { Text, View } from "react-native";
export default function TabHome() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount((c) => c + 1), 1000);
return () => clearInterval(id);
}, []); // eslint may warn: react-hooks/exhaustive-deps
return (
<View>
<Text>Count: {count}</Text>
</View>
);
}app/ matches how Expo Router teams organize features - lint the folder you are actively changing--fix rewrites import order and simple style issues; it will not invent hook dependency arrays for youreact-hooks/exhaustive-deps warnings often surface stale closures in timers and subscriptions - read the message before silencing itnpm run lint remains the merge gate even when you lint locally by pathRelated: React Hooks Lint Rules - exhaustive-deps patterns in async mobile code
App screens run in Hermes or the browser; metro.config.js and app.config.ts run in Node. Expo's preset already relaxes Node globals for common config filenames - extend the list when you add custom tooling files.
// eslint.config.js
const { defineConfig } = require("eslint/config");
const expoConfig = require("eslint-config-expo/flat");
const globals = require("globals");
module.exports = defineConfig([
expoConfig,
{
files: ["metro.config.js", "app.config.ts", "babel.config.js"],
languageOptions: {
globals: globals.node,
},
},
{
ignores: ["dist/*"],
},
]);// metro.config.js - __dirname is valid under Node globals
const { getDefaultConfig } = require("expo/metro-config");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
module.exports = config;__dirname, require, and module as undefined in config fileseslint-config-expo already special-cases several config paths - add explicit files blocks only for custom names (tailwind.config.js, scripts/**)/* eslint-env node */ as a last resort in a single legacy file - prefer centralized languageOptions in flat config.js or .ts at the repo root so the files glob stays simpleRelated: ../project-setup/project-setup-basics.md -
metro.config.jsandapp.config.tslayout
After the baseline passes, add mobile-focused rules: ban raw text outside <Text>, discourage console.log in shipping code, and require testID on pressables you cover in E2E.
// eslint.config.js
const { defineConfig } = require("eslint/config");
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
rules: {
"no-console": ["warn", { allow: ["warn", "error"] }],
"react-native/no-raw-text": [
"error",
{ skip: ["Trans", "Animated.Text"] },
],
},
},
{
ignores: ["dist/*", ".expo/*"],
},
]);// Violation: bare string as a child of View - wrap with Text
import { View, Text, Pressable } from "react-native";
export function SettingsRow({ label }: { label: string }) {
return (
<Pressable accessibilityRole="button" testID="settings-row">
<View>
<Text>{label}</Text>
</View>
</Pressable>
);
}react-native/no-raw-text catches a common crash class on Android - strings must live inside <Text>no-console as warn keeps debug logs visible in review without blocking merges on day oneeslint-plugin-react-native when you enable RN-specific rules: npx expo install eslint-plugin-react-native --devRelated: Custom ESLint Rules for RN - ban
console.log, a11y props, and import boundaries
In a Turborepo or npm-workspaces layout, point ESLint at each app's source roots and share one base config from packages/eslint-config.
{
"name": "mobile",
"private": true,
"scripts": {
"lint": "expo lint app src",
"lint:all": "turbo run lint"
}
}// apps/mobile/eslint.config.js
const { defineConfig } = require("eslint/config");
const expoConfig = require("eslint-config-expo/flat");
const baseRules = require("@myorg/eslint-config/expo");
module.exports = defineConfig([
expoConfig,
baseRules,
{
ignores: ["dist/*", ".expo/*", "ios/**", "android/**"],
},
]);expo lint when app code lives outside app/ (e.g. src/features/)packages/eslint-config so mobile and web apps inherit the same unused-var and import ruleslint:all from the repo root in CI so a shared hook change in packages/ui fails before publishignores block - native folders and build output differ per targetRelated: ../project-setup/monorepo-with-turborepo.md - workspace layout and task graph | CI Quality Gates - matrix builds per app
Treat lint and TypeScript as complementary gates before eas build or a store submission - they catch different defect classes.
{
"scripts": {
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"validate": "npm run typecheck && npm run lint"
}
}npm run validate// TypeScript passes; ESLint flags the hook - validate catches both
import { useCallback, useState } from "react";
import { Button, View } from "react-native";
export function Counter() {
const [n, setN] = useState(0);
const increment = useCallback(() => setN(n + 1), []); // stale closure risk
return (
<View>
<Button title={`${n}`} onPress={increment} />
</View>
);
}tsc --noEmit validates types without emitting JS - fast enough for every PRexpo lint runs the hooks plugin - stale closures like useCallback(..., []) reading n are lint findings, not type errorsvalidate or check and call it from CI, pre-push hooks, and local pre-release ritualsvalidate - never skip the gate because the simulator launchedRelated: TypeScript Strict Mode - tighten
strictincrementally | Linting Best Practices - zero-warning policy without slowing velocity
Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026