AlgoInsight

Free lesson · React Deep Dive

TypeScript with Function Components: The Basics

Hard9 min readreactjavascriptfrontend

Lesson 24.1 - TypeScript with Function Components: The Basics

The foundations lesson.

This lesson covers the baseline patterns for typing React function components - the choices that have settled across the React TypeScript community, the patterns that work, and the ones that have fallen out of favor. By the end, you'll know how to type a function component idiomatically and why the "obvious" patterns from a few years ago are no longer recommended.

The framing: the React + TypeScript community has converged on specific conventions. Following them produces code that other React engineers can read and work with. Deviating produces code that's technically valid but reads as off. This lesson covers the conventions.


The Basic Pattern

The recommended way to type a function component in 2026:

type ButtonProps = {
  label: string
  onClick: () => void
}

function Button({ label, onClick }: ButtonProps) {
  return <button onClick={onClick}>{label}</button>
}

A few things to note:

That's the modern pattern. It's what you'll see in current docs, current codebases, current libraries.


Why Not React.FC?

A specific piece of history worth understanding. From around 2017 to 2020, the recommended pattern was:

const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
  return <button onClick={onClick}>{label}</button>
}

React.FC (or React.FunctionComponent) is a type that takes props and returns a typed function component. For years, this was the recommended way.

By 2026, the community has moved away from React.FC for several reasons:

1. It implicitly added children. React.FC<Props> typed children as ReactNode even when the component didn't accept children. This produced false-positive types - TypeScript said children was valid when the component would ignore them.

This was changed in @types/react 18+; React.FC no longer implies children. But the change was disruptive and the broader confusion remains.

2. It can't be used with generic components. Generic function components (covered in 24.3) need different syntax than React.FC supports.

3. It adds no value over the explicit pattern. Writing function Button({...}: Props) is shorter than const Button: React.FC<Props> = ({...}) =>. The named function form has better stack traces in errors. The explicit form makes the type relationship clearer.

4. The community converged on the explicit form. Major projects (shadcn/ui, the React docs, most current tutorials) use the explicit form. React.FC looks dated to 2026-trained eyes.

For existing codebases that use React.FC: it still works; no urgent migration. For new code: use the explicit form.


function vs const Declaration

A specific style choice. Two ways to declare a function component:

// Function declaration
function Button({ label, onClick }: ButtonProps) {
  return <button onClick={onClick}>{label}</button>
}

// Arrow function (const)
const Button = ({ label, onClick }: ButtonProps) => {
  return <button onClick={onClick}>{label}</button>
}

Both work. The differences are stylistic and minor:

function declaration:

Arrow function (const):

For most projects, either is fine. Some teams have lint rules enforcing one or the other. The most common modern convention is function declarations for components, arrow functions for inner callbacks and hooks.

A specific case: for higher-order components and components that need to be passed around as values, the arrow form is often more natural (you can immediately use it as a value). For top-level components in their own files, the function form is conventional.

We use function declarations throughout this module for clarity.


Return Type: Implicit vs Explicit

A choice. Two patterns:

// Implicit  - let TypeScript infer
function Button(props: ButtonProps) {
  return <button>{props.label}</button>
}

// Explicit
function Button(props: ButtonProps): JSX.Element {
  return <button>{props.label}</button>
}

The recommendation: let TypeScript infer. The explicit return type adds noise without value in most cases. TypeScript infers JSX.Element from the returned JSX automatically.

Specific cases where explicit return types help:

For most components, inference is fine. Don't add explicit return types as a habit.


The Recommended File Structure

A specific convention. A typical component file:

// components/Button.tsx
import { type ReactNode } from 'react'

type ButtonProps = {
  variant?: 'primary' | 'secondary'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  children: ReactNode
  onClick?: () => void
}

export function Button({ 
  variant = 'primary',
  size = 'md',
  disabled = false,
  children,
  onClick,
}: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  )
}

The conventions:

This pattern works for ~80% of components. The remaining 20% need additional patterns (generics, forwardRef in pre-React-19 code, etc.) - covered in subsequent lessons.


Inference Where It Works

A specific principle. Lean on TypeScript's inference where it works; be explicit where it doesn't.

// ✅ Inference works  - count is `number`
const [count, setCount] = useState(0)

// ✅ Inference works  - open is `boolean`
const [open, setOpen] = useState(false)

// ✅ Inference works  - items is `User[]`
const items: User[] = []

For these, no explicit type needed. TypeScript figures it out.

// ❌ Inference falls back to never[]  - must be explicit
const [items, setItems] = useState([])  // useState<never[]>

// ✅ Be explicit
const [items, setItems] = useState<User[]>([])

For useState with an initial value that doesn't carry enough info (empty array, null), explicit generic helps.

// ❌ Inference can't know shape
const [user, setUser] = useState(null)  // useState<null>

// ✅ Be explicit
const [user, setUser] = useState<User | null>(null)

The pattern: if the initial value gives TypeScript what it needs, infer. Otherwise, provide the type.

This applies broadly. Function return values, variable declarations, generic parameters - let TypeScript figure it out where it can; help it where it can't.


tsconfig.json Essentials

A specific configuration concern. For React + TypeScript, your tsconfig.json should include:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["DOM", "DOM.Iterable", "ES2022"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true
  },
  "include": ["src"]
}

The most important options:

For Vite projects, the default tsconfig.json is usually correct. For older Webpack projects or projects originally set up before these were standards, audit the config to make sure these are set.


A Worked Example: A Real Component

Putting the basics together. A modal component:

import { type ReactNode } from 'react'

type ModalProps = {
  isOpen: boolean
  title: string
  children: ReactNode
  onClose: () => void
  size?: 'sm' | 'md' | 'lg'
}

export function Modal({ 
  isOpen, 
  title, 
  children, 
  onClose, 
  size = 'md' 
}: ModalProps) {
  if (!isOpen) return null
  
  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div 
        className={`modal modal-${size}`} 
        onClick={(e) => e.stopPropagation()}
        role="dialog"
        aria-labelledby="modal-title"
      >
        <h2 id="modal-title">{title}</h2>
        <button onClick={onClose} aria-label="Close">×</button>
        <div className="modal-body">{children}</div>
      </div>
    </div>
  )
}

A few things worth noting:

Usage:

<Modal isOpen={open} title="Confirm" onClose={() => setOpen(false)}>
  Are you sure you want to proceed?
</Modal>

TypeScript verifies the props at the call site. Missing required props is an error. Wrong types is an error. The whole stack is type-safe.

This is the baseline pattern. Subsequent lessons cover the more specific patterns (generics, refs, complex prop types, etc.).


A Common Beginner Mistake: Using any

A specific anti-pattern. When TypeScript complains, the path of least resistance is often any:

// ❌ Anti-pattern
function processItem(item: any) {
  return item.name.toUpperCase()
}

any opts out of type checking. The function compiles; at runtime, if item.name doesn't exist, the code crashes.

The right approach: figure out what item should be:

// ✅ Specific type
function processItem(item: { name: string }) {
  return item.name.toUpperCase()
}

// ✅ Or known interface
function processItem(item: User) {
  return item.name.toUpperCase()
}

// ✅ Generic if you don't know
function processItem<T extends { name: string }>(item: T) {
  return item.name.toUpperCase()
}

Each variant gives TypeScript real type information. Bugs are caught at compile time. The any form would catch them only at runtime, often in production.

A useful escape hatch when you genuinely don't know the type: unknown instead of any. unknown is the safe alternative - you can hold any value but can't use it without narrowing.

function processItem(item: unknown) {
  if (typeof item === 'object' && item !== null && 'name' in item) {
    return (item as { name: string }).name.toUpperCase()
  }
  return ''
}

More work to use, but type-safe. Default to unknown when you'd reach for any.


A Note on the React Import

A specific small concern. With the modern JSX transform ("jsx": "react-jsx"), you don't need to import React in every file:

// Old (pre-2020 setup)
import React from 'react'

function Button() {
  return <button>Click</button>
}

// Modern (with react-jsx)
function Button() {
  return <button>Click</button>
}

The JSX transform handles the React reference. You import React only when you actually use it (e.g., React.memo, React.useState).

In practice, you usually import specific names:

import { useState, useEffect, type ReactNode } from 'react'

The type prefix on ReactNode marks it as a type-only import (compiled away, not included in bundle).

For modern projects with "jsx": "react-jsx", this is the convention. For older projects on the classic JSX transform, the import React is still needed.


A Note on Strictness

A specific configuration consideration. "strict": true enables all strict type-checking options:

For modern React projects, strict: true is the right default. Catches real bugs, encourages explicit types, makes the codebase more robust.

Some projects disable specific checks. The most common: noImplicitAny: false for legacy code being migrated. Acceptable as a transitional measure; don't make it permanent.

A modern addition: noUncheckedIndexedAccess: true. Makes arr[0] return T | undefined. Catches a category of off-by-one and missing-element bugs. Adds some friction (you now have to check for undefined more often), but the bugs it catches are real. Recommended for new projects.


What I Want You to Take From This Lesson

Six things.

One: Use function declarations with destructured props. No React.FC.

Two: Let TypeScript infer return types in most cases. Be explicit only when needed.

Three: Named props type colocated with the component, named <ComponentName>Props.

Four: Default values in destructuring. TypeScript narrows the type after the default.

Five: Use type modifier on type-only imports - import { type ReactNode }.

Six: strict: true and noUncheckedIndexedAccess: true in tsconfig. Catch real bugs.


Try It

Exercise: Warmup (20 minutes). Convert a JavaScript React component to TypeScript. Add the props type. Verify no errors.

Exercise: Core (30 minutes). If your project uses React.FC, migrate three components to the explicit pattern. Verify nothing breaks.

Exercise: Core (30 minutes). Audit your tsconfig.json. Make sure strict is on. Try noUncheckedIndexedAccess; fix any errors that come up.

Exercise: Stretch (45 minutes). Read the React TypeScript Cheatsheet's "Function Components" section. Compare to what you wrote.


What's Next

Lesson 24.2 covers typing props in depth - required vs optional, defaults, discriminated unions, extending HTML props.

Back to React Deep Dive