Building modern React applications with TypeScript isn’t just about adding some type annotations and hoping for the best. It’s about setting up a foundation that helps you catch bugs before they reach production, provides excellent developer experience, and scales gracefully as your team and codebase grow. React 19 brings some exciting improvements to how we write components, and when paired with TypeScript’s latest features, you get a development experience that’s both powerful and pleasant.
In this guide, we’ll build a React 19 + TypeScript project from the ground up, covering everything from tsconfig.json configuration to component patterns that’ll make your future self thank you.
Setting Up Your Development Environment
Before we dive into the fun stuff, let’s make sure we have the right tools installed. You’ll want Node.js 18 or higher—React 19 requires it—and your favorite package manager. I’ll use npm in the examples, but feel free to swap in yarn or pnpm or bun.
Try npm create vite@latest and follow the prompts if you want to make this easy on yourself.
But, let’s say that we wanted to do this by hand.
# Create a new project directory
mkdir your-project-here
cd your-project-here
# Initialize package.json
npm init -yNow let’s install React 19 and TypeScript. Note that React 19 is currently in beta, so we’ll need to install the beta versions:
# Install React
npm install react react-dom
# Install TypeScript and related dev dependencies
npm install -D typescript @types/react @types/react-dom @types/node
# Install build tools (we'll use Vite for this example)
npm install -D vite @vitejs/plugin-reactConfiguring TypeScript for React
The tsconfig.json is where the magic happens. React 19 introduces some changes that affect how we configure TypeScript, particularly around the new JSX runtime and improved type checking.
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
// React 19 specific improvements
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Allow me to break down the React 19-specific bits:
jsx: "react-jsx": Uses the new JSX transform—no moreimport React from 'react'in every file!exactOptionalPropertyTypes: true: Prevents accidentally passingundefinedto optional props.noUncheckedIndexedAccess: true: Forces you to handle potentialundefinedvalues when accessing arrays or objects with dynamic keys.
exactOptionalPropertyTypes setting is particularly helpful with React props. It ensures that if a prop is optional, you can’t accidentally pass undefined to it—you have to either pass a real value or omit it entirely.
ESLint: An Extra Layer of Protection
We can also set up ESLint add additional checks on our code. Using ESLint with TypeScript requires a little extra tweaking that is probably worth talking about. But, first—let’s install some dependencies.
npm install -D eslint @eslint/js @types/eslint__js typescript-eslint eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-react-refreshOkay, here is a reasonable template that you can copy and paste.
import js from '@eslint/js'; // ESLint's core recommended JavaScript rules
import globals from 'globals'; // Provides global variable definitions for different environments
import typescript from 'typescript-eslint'; // TypeScript ESLint parser and rules
import react from 'eslint-plugin-react'; // React-specific linting rules
import reactHooks from 'eslint-plugin-react-hooks'; // Rules for React Hooks usage
import reactRefresh from 'eslint-plugin-react-refresh'; // React Fast Refresh validation
/** @type {import('eslint').Linter.Config[]} */ // TypeScript type annotation for config array
export default [
js.configs.recommended, // Enable ESLint's recommended JavaScript rules
...typescript.configs.strict, // Apply TypeScript's strict ruleset (includes recommended + type-checked rules)
{
files: ['**/*.{ts,tsx}'], // Apply this config to TypeScript and TSX files only
languageOptions: {
globals: {
...globals.browser, // Include browser global variables (window, document, etc.)
...globals.es2020, // Include ES2020 global features (Promise, BigInt, etc.)
},
parserOptions: {
project: true, // Enable TypeScript project for type-aware linting
ecmaVersion: 'latest', // Use the latest ECMAScript version
ecmaFeatures: { jsx: true }, // Enable JSX parsing
sourceType: 'module', // Treat files as ES modules
},
},
settings: {
react: { version: 'detect' }, // Auto-detect React version for appropriate rules
},
plugins: {
react, // Register React plugin
'react-hooks': reactHooks, // Register React Hooks plugin
'react-refresh': reactRefresh, // Register React Refresh plugin
},
rules: {
...react.configs.recommended.rules, // Apply React's recommended rules
...react.configs['jsx-runtime'].rules, // Rules for new JSX transform (no React import needed)
...reactHooks.configs.recommended.rules, // Apply React Hooks rules (deps, exhaustive-deps)
// React Refresh
'react-refresh/only-export-components': [
// Warn when files export non-components (breaks Fast Refresh)
'warn',
{
allowConstantExport: true, // Allow exporting constants alongside components
},
],
// TypeScript
'@typescript-eslint/no-unused-vars': [
// Error on unused variables
'error',
{
argsIgnorePattern: '^_', // Ignore unused args starting with underscore
varsIgnorePattern: '^_', // Ignore unused vars starting with underscore
},
],
'@typescript-eslint/explicit-function-return-type': 'off', // Don't require return types on functions
'@typescript-eslint/explicit-module-boundary-types': 'off', // Don't require types on module boundaries
'@typescript-eslint/no-explicit-any': 'error', // Disallow 'any' type usage
'react/prop-types': 'off', // TypeScript handles this
},
},
{
files: ['**/*.js'], // Apply to plain JavaScript files
...typescript.configs.disableTypeChecked, // Disable TS rules for JS files
},
{
ignores: ['dist', 'build', 'node_modules', 'coverage', '*.min.js'], // Skip linting these paths/patterns
},
];Let’s explain each of these.
eslint-plugin-react
Purpose: Core React linting rules for JSX and React-specific patterns.
What it catches:
- Missing
keyprops in lists - Invalid prop types
- Unused state and props
- Direct state mutations
- Accessibility issues in JSX
- React best practices violations
// Basic setup
rules: {
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules, // For React 17+ JSX transform
}
// Popular custom rules
rules: {
// Component and prop naming
'react/jsx-pascal-case': ['error', { allowAllCaps: true }],
'react/jsx-curly-brace-presence': ['error', { props: 'never', children: 'never' }],
// Props and state
'react/prop-types': 'off', // Turn off if using TypeScript
'react/require-default-props': 'off', // Often disabled with TypeScript
'react/destructuring-assignment': ['error', 'always'],
'react/no-unused-prop-types': 'error',
'react/no-unused-state': 'error',
// JSX formatting
'react/jsx-indent': ['error', 2],
'react/jsx-indent-props': ['error', 2],
'react/jsx-max-props-per-line': ['error', { maximum: 1, when: 'multiline' }],
'react/jsx-first-prop-new-line': ['error', 'multiline'],
'react/jsx-closing-bracket-location': ['error', 'line-aligned'],
// Security and performance
'react/no-danger': 'warn',
'react/no-array-index-key': 'warn',
'react/jsx-no-target-blank': ['error', { enforceSynamicLinks: 'always' }],
}eslint-plugin-react-hooks
Purpose: Enforces the Rules of Hooks - React’s critical rules for using hooks correctly.
What it catches:
- Hooks called conditionally (violates rules of hooks)
- Missing dependencies in
useEffect,useMemo,useCallback - Hooks called outside of components or custom hooks
- Custom hooks not starting with “use”
Common configurations:
// Standard (recommended for most projects)
rules: {
...reactHooks.configs.recommended.rules,
// This expands to:
'react-hooks/rules-of-hooks': 'error', // Checks rules of hooks
'react-hooks/exhaustive-deps': 'warn', // Checks effect dependencies
}
// Stricter configuration
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error', // Error instead of warning
}
// More lenient (not recommended)
rules: {
'react-hooks/rules-of-hooks': 'error', // Never disable this!
'react-hooks/exhaustive-deps': ['warn', {
additionalHooks: '(useMyCustomEffect|useAsync)', // Check custom hooks
enableDangerousAutofixThisMayCauseInfiniteLoops: false, // Disable autofix
}],
}Common patterns people use:
// Some teams disable exhaustive-deps for specific cases
'react-hooks/exhaustive-deps': ['warn', {
additionalHooks: 'useUpdateEffect|useIsomorphicLayoutEffect',
}]
// NEVER do this (but some people unfortunately do):
// 'react-hooks/exhaustive-deps': 'off' // ❌ Bad idea!eslint-plugin-react-refresh
Purpose: Ensures components are compatible with React Fast Refresh (Vite’s hot reload).
What it catches:
- Components exported alongside non-component exports
- Anonymous default exports that break Fast Refresh
- Class components (which don’t work well with Fast Refresh)
Common configurations:
// Basic setup (most common)
rules: {
'react-refresh/only-export-components': ['warn', {
allowConstantExport: true // Allow const exports alongside components
}],
}
// Stricter version
rules: {
'react-refresh/only-export-components': ['error', {
allowConstantExport: false // Only allow component exports
}],
}
// More permissive
rules: {
'react-refresh/only-export-components': ['warn', {
allowConstantExport: true,
allowExportNames: ['meta', 'links', 'handle', 'loader'], // For frameworks like Remix
}],
}Prettier: Consistent Code Formatting
While ESLint handles code quality, Prettier handles formatting. They work great together when configured properly.
Installation
npm install -D prettier eslint-config-prettierPrettier Configuration
Create .prettierrc.json:
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}And .prettierignore:
Integrating with ESLint
To avoid conflicts between ESLint and Prettier, add eslint-config-prettier to your ESLint config. This disables ESLint rules that would conflict with Prettier’s formatting.
Package Scripts
Add these scripts to your package.json for a smooth development workflow:
{
"scripts": {
// Development
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
// Type checking
"type-check": "tsc --noEmit",
"type-check:watch": "tsc --noEmit --watch",
// Linting and formatting
"lint": "eslint . --ext .ts,.tsx,.js,.jsx",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
// Combined checks (useful for CI/pre-commit)
"check-all": "npm run type-check && npm run lint && npm run format:check",
"fix-all": "npm run lint:fix && npm run format"
}
}Development Workflow
During development, run these in separate terminals for the best experience:
# Terminal 1: Dev server with hot reload
npm run dev
# Terminal 2: Type checking in watch mode
npm run type-check:watchBefore committing:
# Check everything
npm run check-all
# Or auto-fix what's possible
npm run fix-allReal-World Configuration Examples
Strict Configuration (Enterprise/Large Teams)
rules: {
// React
...react.configs.recommended.rules,
'react/jsx-no-leaked-render': ['error', { validStrategies: ['coerce'] }],
'react/jsx-handler-names': ['error', {
eventHandlerPrefix: 'handle',
eventHandlerPropPrefix: 'on',
}],
'react/hook-use-state': 'error',
'react/no-unstable-nested-components': 'error',
// Hooks
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
// Fast Refresh
'react-refresh/only-export-components': 'error',
}Balanced Configuration (Most Projects)
rules: {
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/prop-types': 'off', // Using TypeScript
'react/display-name': 'off', // Often too noisy
'react-refresh/only-export-components': ['warn', {
allowConstantExport: true
}],
}Minimal Configuration (Prototypes/Small Projects)
rules: {
'react/jsx-key': 'error', // Critical for React
'react-hooks/rules-of-hooks': 'error', // Critical for hooks
'react-hooks/exhaustive-deps': 'warn', // Helpful but not blocking
'react-refresh/only-export-components': 'warn', // Nice for DX
}Common Pitfalls
- Don’t disable
rules-of-hooks: This can cause React to behave unpredictably. - Be careful with
exhaustive-deps: It’s annoying but usually right. react-refreshis Vite-specific: You don’t need it for Create React App or Next.js- TypeScript makes many React rules redundant: You can disable
prop-typesrelated rules - Some rules conflict with Prettier - Disable formatting rules if using Prettier.
You can check that list out here.
package,json Scripts
Add these scripts to your package.json:
{
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix"
}
}Some Other Best Practices
For stricter type checking, enable type-aware rules by adding project configuration:
If you’re using Bun or Deno, you can use import.meta.env. But, if you’re using Node, you’ll need to create your own polyfill.
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
}Again, in Deno or Bun, you can do this instead:
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
}Then use typescript.configs.recommendedTypeChecked instead of just recommended.
2. Custom Rules for Your Team Add project-specific rules based on your team’s conventions:
rules: {
'react/prop-types': 'off', // TypeScript handles this
'react/react-in-jsx-scope': 'off', // Not needed with React 17+
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'error',
}Optional: Setting Up Visual Studio Code and/or Cursor
If you’re using Visual Studio Code or Cursor, you might want to install the ESLint extension.
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.validate": ["javascript", "typescript", "javascriptreact", "typescriptreact"]
}Optional: Setting Up Pre-Commit Hooks
Use Husky and lint-staged to enforce linting before commits:
npm install -D husky lint-staged
npx husky initAdd to package.json:
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "git add"]
}
}