Mastering React Components — Named vs Default Exports, PropTypes vs TypeScript, and Production-Ready Patterns
You’ve probably seen the same component written three different ways:
// 1) Named export
export const RepoStats = () => {
return <div>RepoStats</div>
}
// 2) Default export
const RepoStats = () => {
return <div>RepoStats</div>
}
export default RepoStats
// 3) With PropTypes (runtime validation)
import PropTypes from 'prop-types'
const RepoStats = (props) => {
return <div>RepoStats</div>
}
RepoStats.propTypes = {}
export default RepoStats
They all render the same <div>RepoStats</div>
, but their implications for API design, DX, testing, and scale are very different. In this post, we’ll treat this simple example as a springboard to expert-level React component patterns you can use in real projects.
1) Named vs Default Exports — Which should you prefer?
TL;DR: Prefer named exports for most components. Use default exports for framework‑constrained files (e.g. Next.js pages, Remix routes) or when you truly want “one canonical export per file”.
Why many teams prefer named exports
-
Refactors are safer: Rename a component and your tooling updates imports automatically (
import { RepoStats } from ...
). - Auto‑imports are better: Editors can reliably suggest the right symbol.
-
Avoids “mystery names”:
import Widget from './RepoStats'
compiles, even if the symbol is actuallyRepoStats
. With named exports, consumers must ask for the real name. - Tree‑shaking is great either way with ESM, but named exports make dead‑code elimination more predictable in some bundlers.
When default exports still shine
- You intentionally want
import RepoStats from '...'
for ergonomic DX. - Framework conventions require default (e.g. Next.js pages/components in specific folders).
- You are exporting a single object that feels like “the module”.
Team rule of thumb (a common ESLint setup):
// .eslintrc excerpt
{
"rules": {
"import/no-default-export": "warn" // prefer named
}
}
Pragmatic exception: allow defaults in specific folders via overrides (e.g.
/app/**
in Next.js).
2) Function Declarations vs Arrow Functions
Both are valid for function components. Most codebases use arrow functions:
export const RepoStats = () => <div>RepoStats</div>
Key differences:
- Hoisting: function declarations are hoisted; arrows aren’t. For components, hoisting rarely matters because we import from other files.
-
Statics: you can attach statics to either, but arrows make
React.memo
andforwardRef
composition more ergonomic:
const RepoStatsBase = ({ title = 'RepoStats' }) => <div>{title}</div>
export const RepoStats = Object.assign(React.memo(RepoStatsBase), {
displayName: 'RepoStats'
})
displayName
helps DevTools, especially after HOCs.
3) Props: PropTypes (runtime) vs TypeScript (compile‑time)
PropTypes
- Runtime validation: helpful when publishing JS packages, or when users don’t use TypeScript.
- Catches incorrect usage in production (dev only by default, but can be bundled intentionally).
- Limited expressiveness vs TypeScript.
import PropTypes from 'prop-types'
export const RepoStats = ({ owner, repo, showStars = true }) => (
<section>
<h3>{owner}/{repo}</h3>
{showStars && <span>⭐ 1234</span>}
</section>
)
RepoStats.propTypes = {
owner: PropTypes.string.isRequired,
repo: PropTypes.string.isRequired,
showStars: PropTypes.bool
}
TypeScript
- Best DX: autocomplete, inline docs, strict unions, generics.
- Prevents bugs before runtime.
- You can still ship PropTypes for JS consumers (dual safety).
type RepoStatsProps = {
owner: string
repo: string
showStars?: boolean
children?: React.ReactNode
}
export const RepoStats: React.FC<RepoStatsProps> = ({
owner,
repo,
showStars = true,
children
}) => {
return (
<section aria-label="Repository statistics">
<h3>{owner}/{repo}</h3>
{showStars && <span>⭐ 1234</span>}
{children}
</section>
)
}
Avoid
React.FC
? It’s fine to use. If you prefer, just write({ children }: Props) => …
and typechildren
explicitly.
4) Default Props — Use default parameters, not Component.defaultProps
For function components, prefer default parameters:
export const RepoStats = ({ showStars = true }) => { /* ... */ }
RepoStats.defaultProps
still works, but default parameters are simpler and type‑safe.
5) Composition, children
, and as
props
Design components for composition:
type RepoStatsProps = {
owner: string
repo: string
footer?: React.ReactNode
children?: React.ReactNode
}
export function RepoStats({ owner, repo, children, footer }: RepoStatsProps) {
return (
<article>
<header><strong>{owner}/{repo}</strong></header>
<div>{children}</div>
{footer && <footer>{footer}</footer>}
</article>
)
}
For highly reusable UI primitives, consider a polymorphic as
prop and forwarding refs:
import React, { forwardRef, ElementType, ComponentPropsWithoutRef } from 'react'
type BoxProps<T extends ElementType> = {
as?: T
} & Omit<ComponentPropsWithoutRef<T>, 'as'>
export const Box = forwardRef(
<T extends ElementType = 'div'>(
{ as, ...rest }: BoxProps<T>,
ref: React.Ref<Element>
) => {
const Tag = as || 'div'
return <Tag ref={ref as any} {...rest} />
}
)
Box.displayName = 'Box'
6) Performance: React.memo
, stable handlers, and when not to optimize
- Wrap leaf components with
React.memo
when rerenders are frequent and props are stable. - Use
useCallback
/useMemo
to stabilize expensive functions/values. Don’t sprinkle them everywhere—measure first. - Prefer keys that don’t change and avoid array index keys in dynamic lists.
- With lists, memoize item rows; pass stable item props.
import { memo, useCallback } from 'react'
type RowProps = { id: string; onToggle(id: string): void }
const Row = memo(({ id, onToggle }: RowProps) => (
<button onClick={() => onToggle(id)}>{id}</button>
))
export function List({ items }: { items: string[] }) {
const onToggle = useCallback((id: string) => { /* ... */ }, [])
return items.map((id) => <Row key={id} id={id} onToggle={onToggle} />)
}
7) File Organization & Re‑exports
-
One component per file; colocate tests & stories:
RepoStats/RepoStats.tsx
,RepoStats/RepoStats.test.tsx
,RepoStats/RepoStats.stories.tsx
- Barrel export for ergonomics:
// src/components/index.ts
export { RepoStats } from './RepoStats/RepoStats'
export { Box } from './Box/Box'
Consumers can now:
import { RepoStats, Box } from '@/components'
8) Testing Considerations (named vs default)
- Named export is easier to mock & import in isolation:
import { RepoStats } from './RepoStats'
- With defaults, you often need:
import RepoStats from './RepoStats'
- Both are fine—just stay consistent with your chosen convention.
9) A Production‑Ready RepoStats
(two flavors)
A) TypeScript + React.memo
+ forwardRef
import React, { forwardRef, memo } from 'react'
type RepoStatsProps = {
owner: string
repo: string
stars?: number
watchers?: number
forks?: number
className?: string
}
const RepoStatsBase = forwardRef<HTMLDivElement, RepoStatsProps>(
({ owner, repo, stars, watchers, forks, className }, ref) => {
return (
<section ref={ref} className={className} aria-label="Repository statistics">
<header><strong>{owner}/{repo}</strong></header>
<ul>
{typeof stars === 'number' && <li>⭐ {stars}</li>}
{typeof watchers === 'number' && <li>👀 {watchers}</li>}
{typeof forks === 'number' && <li>🍴 {forks}</li>}
</ul>
</section>
)
}
)
RepoStatsBase.displayName = 'RepoStats'
export const RepoStats = memo(RepoStatsBase)
B) JavaScript + PropTypes (for libraries shipping JS)
import React from 'react'
import PropTypes from 'prop-types'
const RepoStats = ({ owner, repo, stars, watchers, forks, className }) => (
<section className={className} aria-label="Repository statistics">
<header><strong>{owner}/{repo}</strong></header>
<ul>
{Number.isFinite(stars) && <li>⭐ {stars}</li>}
{Number.isFinite(watchers) && <li>👀 {watchers}</li>}
{Number.isFinite(forks) && <li>🍴 {forks}</li>}
</ul>
</section>
)
RepoStats.propTypes = {
owner: PropTypes.string.isRequired,
repo: PropTypes.string.isRequired,
stars: PropTypes.number,
watchers: PropTypes.number,
forks: PropTypes.number,
className: PropTypes.string
}
export default RepoStats
10) Accessibility & Semantics (don’t skip this)
- Use meaningful landmarks:
section
,header
,nav
,main
,footer
. - Add aria‑labels only when semantics aren’t obvious.
- Ensure color contrast; don’t rely solely on color to convey state.
- Keyboard first: focus states,
tabIndex
, actionable components as real buttons/links.
11) Putting it all together — Decision Checklist
- Export: named (prefer), default when framework demands it.
- Typing: TypeScript in app code; PropTypes if shipping JS for others.
-
Defaults: parameter defaults, not
defaultProps
(for FCs). -
Composition: accept
children
; consideras
andforwardRef
for primitives. -
Performance:
memo
leaf nodes; measure before optimizing; stable handlers. - DX: set ESLint/prettier rules; add barrel exports for ergonomics.
- A11y: semantics first; test with keyboard & screen reader.
Conclusion
All three versions of RepoStats
render the same thing, but they signal very different architectural decisions. By choosing your export strategy, typing approach, and composition patterns deliberately, you’ll ship components that are easier to use, safer to change, and ready for scale.
Pick a convention, enforce it with lint rules, and document it in your project README. Your future self (and your teammates) will thank you.
✍️ Written by: Cristian Sifuentes — Full‑stack developer & AI/JS enthusiast. I design scalable React architectures and teach teams how to ship clean, accessible, and maintainable UI.
Tags: #react #frontend #programming #components #bestpractices
Top comments (0)