Free lesson · React Deep Dive
TypeScript with Function Components: The Basics
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:
- Props as a named type (
ButtonProps). Conventional name:<ComponentName>Props. - Function declaration with destructured props in the parameter.
- Implicit return type - TypeScript infers it. You don't write
: JSX.Elementor: React.ReactNodeexplicitly. - No
React.FC- covered shortly.
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:
- Hoisted (you can use it before its declaration in the file).
displayNameis automatically set from the function name (useful for React DevTools).- Sometimes better error messages.
Arrow function (const):
- Not hoisted; must be declared before use.
displayNamenot automatic (though for default exports, React still infers).- Slightly more compact for one-liners.
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:
-
The component might return null or other non-element values. Type as
ReactNodeexplicitly.function ConditionalContent(props: Props): ReactNode { if (!props.show) return null return <div>...</div> }Without the explicit type, TypeScript would narrow to
JSX.Element | null, which is fine butReactNodeis more general. -
Library types want specific return types. Some library types expect
ReactElementrather thanReactNode. The explicit form clarifies what you're providing.
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:
- Type imports use
typemodifier -import { type ReactNode }. Helps TypeScript know it's a type-only import (smaller compiled output, no accidental runtime usage). - Props type colocated with the component, named
<Component>Props. - Defaults in destructuring -
variant = 'primary'. TypeScript narrows the type after the default is applied. - Named export, function declaration. Consistent across the codebase.
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:
"jsx": "react-jsx"- uses the modern JSX transform. No need to import React in every file."strict": true"- turns on all strict type checks. Don't disable unless you have a strong reason."noUncheckedIndexedAccess": true"-arr[0]returnsT | undefinedinstead ofT. Catches real bugs."moduleResolution": "Bundler"- works with Vite, Webpack, etc. The modern default."isolatedModules": true"- required for tools like Vite that compile files independently.
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:
- Props with required and optional members.
sizeis optional with a default; the others are required. - Children typed as
ReactNode. Accepts any renderable content. - Default value in destructuring for the optional prop.
- Return type is inferred - TypeScript sees the conditional return of null and the JSX return, infers the union.
- Inline event handlers with TypeScript's type inference figuring out the event types.
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:
noImplicitAny- error on implicitanytypes.strictNullChecks-nullandundefinedare not assignable to other types.strictFunctionTypes- function parameter types are checked strictly.strictBindCallApply-bind/call/applyare checked.strictPropertyInitialization- class properties must be initialized.alwaysStrict- emit"use strict".
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.