Tutorial React TypeScript: construye interfaces interactivas con componentes React paso a paso
Este tutorial React TypeScript está dirigido a desarrolladores web de nivel intermedio que desean crear interfaces interactivas React de forma tipada, robusta y escalable. A lo largo de este recorrido aprenderás a:
- Configurar un entorno moderno con Vite, React 18 y TypeScript.
- Crear componentes funcionales de manera profesional.
- Manejar estado con hooks (useState, useEffect, useReducer) y crear hooks personalizados.
- Aprovechar el tipado estático con TypeScript (interfaces, tipos discriminados y genéricos).
- Comunicar componentes mediante props, callbacks y Context API.
Al final tendrás una base sólida para tu desarrollo frontend con TypeScript y podrás construir componentes React paso a paso con confianza.
Requisitos previos
- Conocimientos básicos de JavaScript/TypeScript (tipos, interfaces, funciones).
- Conocimientos básicos de React (JSX, componentes, props, estado).
- Node.js LTS instalado (18 o superior) y un gestor de paquetes (npm, pnpm o yarn).
Paso 1: Configuración del entorno
1.1 Crear el proyecto con Vite y TypeScript
Vite es una excelente opción para proyectos modernos por su velocidad y DX. Para crear una plantilla de React con TypeScript:
1 2 3 4 5 6 7 8 9 |
# con npm npm create vite@latest mi-app-react-ts -- --template react-ts # con pnpm pnpm create vite mi-app-react-ts --template react-ts # con yarn yarn create vite mi-app-react-ts --template react-ts |
Instala dependencias y ejecuta el servidor de desarrollo:
1 2 3 4 |
cd mi-app-react-ts npm install npm run dev |
Abre la URL indicada (por defecto, http://localhost:5173) para ver la aplicación inicial.
1.2 Estructura del proyecto sugerida
Una estructura clara facilita el mantenimiento:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
mi-app-react-ts/ ├─ src/ │ ├─ components/ │ │ ├─ common/ │ │ └─ todo/ │ ├─ hooks/ │ ├─ context/ │ ├─ types/ │ ├─ App.tsx │ └─ main.tsx ├─ index.html ├─ tsconfig.json ├─ vite.config.ts └─ package.json |
- components: componentes reutilizables y por dominio.
- hooks: hooks personalizados.
- context: proveedores y hooks de contexto.
- types: tipos e interfaces compartidas.
1.3 Ajustes de calidad (opcional pero recomendado)
Instala ESLint y Prettier para consistencia:
1 2 |
npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier prettier |
Configura un script lint en package.json y un archivo .eslintrc.cjs. Mantener el código consistente reduce errores y mejora la legibilidad.
Paso 2: Tu primer componente funcional con TypeScript
2.1 Componente funcional tipado
Define los props con una interface y utiliza TSX. Evita React.FC si no necesitas children implícitos; tipar props directamente es más explícito.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// src/components/common/Greeting.tsx import React from 'react' interface GreetingProps { name: string highlight?: boolean } export function Greeting({ name, highlight = false }: GreetingProps) { return ( <h2 style={{ color: highlight ? 'tomato' : 'inherit' }}> Hola, {name} </h2> ) } |
En App.tsx úsalo así:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// src/App.tsx import React from 'react' import { Greeting } from './components/common/Greeting' export default function App() { return ( <main> <h1>Tutorial React TypeScript</h1> <Greeting name='Desarrollador' highlight /> </main> ) } |
2.2 Tipar eventos y handlers
TypeScript ayuda a prevenir errores comunes al manejar eventos.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
// src/components/common/Counter.tsx import React from 'react' interface CounterProps { initial?: number onChange?: (value: number) => void } export function Counter({ initial = 0, onChange }: CounterProps) { const [count, setCount] = React.useState<number>(initial) const increment = () => { const next = count + 1 setCount(next) onChange?.(next) } const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => { const value = Number(e.target.value) if (!Number.isNaN(value)) { setCount(value) onChange?.(value) } } return ( <div> <button type='button' onClick={increment} aria-label='Incrementar'> + </button> <input type='number' value={count} onChange={handleInput} aria-label='Contador' /> <span>Valor: {count}</span> </div> ) } |
Observa el tipo React.ChangeEvent
Paso 3: Manejo de estado con hooks
3.1 useState con tipos inferidos y explícitos
TypeScript infiere el tipo a partir del valor inicial, pero puedes ser explícito si el estado puede ser null o múltiples variantes.
1 2 3 |
const [name, setName] = React.useState('') const [age, setAge] = React.useState<number | null>(null) |
3.2 useEffect para efectos y peticiones
Ejemplo de carga de datos tipada:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
interface User { id: number name: string email: string } export function UsersList() { const [users, setUsers] = React.useState<User[]>([]) const [loading, setLoading] = React.useState<boolean>(false) const [error, setError] = React.useState<string | null>(null) React.useEffect(() => { const controller = new AbortController() const run = async () => { try { setLoading(true) setError(null) const res = await fetch('https://jsonplaceholder.typicode.com/users', { signal: controller.signal, }) if (!res.ok) throw new Error('Error de red') const data = (await res.json()) as User[] setUsers(data) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return setError('No se pudo cargar') } finally { setLoading(false) } } run() return () => controller.abort() }, []) if (loading) return <p>Cargando...</p> if (error) return <p>{error}</p> return ( <ul> {users.map(u => ( <li key={u.id}>{u.name}</li> ))} </ul> ) } |
3.3 useReducer para estados complejos
Cuando el estado tiene múltiples transiciones, useReducer ofrece claridad.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
interface Todo { id: string text: string done: boolean } type Action = | { type: 'add'; text: string } | { type: 'toggle'; id: string } | { type: 'remove'; id: string } function todosReducer(state: Todo[], action: Action): Todo[] { switch (action.type) { case 'add': return [...state, { id: crypto.randomUUID(), text: action.text, done: false }] case 'toggle': return state.map(t => (t.id === action.id ? { ...t, done: !t.done } : t)) case 'remove': return state.filter(t => t.id !== action.id) default: return state } } |
3.4 Hook personalizado genérico
Un hook useFetch tipado con genéricos mejora la reutilización.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// src/hooks/useFetch.ts import React from 'react' export function useFetch<T>(url: string) { const [data, setData] = React.useState<T | null>(null) const [loading, setLoading] = React.useState(false) const [error, setError] = React.useState<string | null>(null) React.useEffect(() => { const controller = new AbortController() const run = async () => { try { setLoading(true) setError(null) const res = await fetch(url, { signal: controller.signal }) if (!res.ok) throw new Error('Error de red') const json = (await res.json()) as T setData(json) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return setError('Error cargando datos') } finally { setLoading(false) } } run() return () => controller.abort() }, [url]) return { data, loading, error } } |
Uso:
1 2 3 |
interface Post { id: number; title: string } const { data: posts, loading } = useFetch<Post[]>('https://jsonplaceholder.typicode.com/posts') |
Paso 4: Tipado estático avanzado en componentes
4.1 Props opcionales, valores por defecto y narrowing
El patrón de destructurar con valores por defecto funciona bien en TS y permite narrowing de tipos.
1 2 3 4 5 6 7 8 9 10 11 |
interface AvatarProps { src?: string fallback: string size?: number } export function Avatar({ src, fallback, size = 40 }: AvatarProps) { const style = { width: size, height: size, borderRadius: '50%', objectFit: 'cover' as const } return src ? <img src={src} alt={fallback} style={style} /> : <div aria-label={fallback} style={{ ...style, background: '#ddd' }} /> } |
4.2 Tipos discriminados para variantes de UI
Útil para componentes con variantes exclusivas.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
type ButtonProps = | { variant: 'primary'; onClick: () => void; children: React.ReactNode } | { variant: 'link'; href: string; children: React.ReactNode } export function Button(props: ButtonProps) { if (props.variant === 'primary') { return ( <button type='button' onClick={props.onClick} style={{ background: '#0d6efd', color: '#fff', padding: '0.5rem 1rem' }}> {props.children} </button> ) } return ( <a href={props.href} style={{ color: '#0d6efd', textDecoration: 'underline' }}> {props.children} </a> ) } |
4.3 Componentes genéricos reutilizables
Crea listas u otros contenedores independientes del tipo de dato.
1 2 3 4 5 6 7 8 9 10 |
interface ListProps<T> { items: T[] renderItem: (item: T) => React.ReactNode keyExtractor: (item: T) => string | number } export function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) { return <ul>{items.map(item => <li key={keyExtractor(item)}>{renderItem(item)}</li>)}</ul> } |
Uso:
1 2 3 4 5 |
interface Product { id: string; name: string } const products: Product[] = [{ id: '1', name: 'Teclado' }] <List items={products} keyExtractor={p => p.id} renderItem={p => <span>{p.name}</span>} /> |
Paso 5: Comunicación entre componentes
5.1 Props y callbacks (padre a hijo y viceversa)
- Padre pasa datos y callbacks.
- Hijo notifica cambios ejecutando el callback.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function Parent() { const [value, setValue] = React.useState('') return ( <div> <Child value={value} onChange={setValue} /> <p>Actual: {value}</p> </div> ) } interface ChildProps { value: string onChange: (next: string) => void } function Child({ value, onChange }: ChildProps) { return <input value={value} onChange={e => onChange(e.target.value)} /> } |
5.2 Subida de estado (lifting state up)
Cuando múltiples componentes necesitan el mismo estado, muévelo a su ancestro común y distribuye por props.
5.3 Context API para evitar prop drilling
Tipa el contexto para asegurar seguridad de tipos.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
// src/context/TodoContext.tsx import React from 'react' interface Todo { id: string; text: string; done: boolean } type TodoAction = | { type: 'add'; text: string } | { type: 'toggle'; id: string } | { type: 'remove'; id: string } interface TodoContextValue { state: Todo[] dispatch: React.Dispatch<TodoAction> } const TodoContext = React.createContext<TodoContextValue | undefined>(undefined) function reducer(state: Todo[], action: TodoAction): Todo[] { switch (action.type) { case 'add': return [...state, { id: crypto.randomUUID(), text: action.text, done: false }] case 'toggle': return state.map(t => (t.id === action.id ? { ...t, done: !t.done } : t)) case 'remove': return state.filter(t => t.id !== action.id) default: return state } } export function TodoProvider({ children }: { children: React.ReactNode }) { const [state, dispatch] = React.useReducer(reducer, []) const value = React.useMemo(() => ({ state, dispatch }), [state]) return <TodoContext.Provider value={value}>{children}</TodoContext.Provider> } export function useTodos() { const ctx = React.useContext(TodoContext) if (!ctx) throw new Error('useTodos debe usarse dentro de TodoProvider') return ctx } |
Paso 6: Construir una interfaz interactiva completa
A continuación, juntamos piezas para crear una pequeña interfaz: un gestor de tareas con filtro. Usaremos componentes React paso a paso, hooks, tipado de acciones y contexto.
6.1 Tipos compartidos
1 2 3 4 5 6 7 8 9 |
// src/types/todo.ts export interface Todo { id: string text: string done: boolean } export type Filter = 'all' | 'active' | 'completed' |
6.2 Provider y estado global
Puedes reutilizar el TodoProvider del apartado anterior y añadir estado local para el filtro en el componente raíz.
6.3 Componentes UI
- TodoInput: añade nuevas tareas.
- TodoList: muestra la lista filtrada.
- TodoItem: alterna estado y elimina.
- TodoFilter: cambia el filtro activo.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// src/components/todo/TodoInput.tsx import React from 'react' import { useTodos } from '../../context/TodoContext' export function TodoInput() { const { dispatch } = useTodos() const [text, setText] = React.useState('') const submit = (e: React.FormEvent) => { e.preventDefault() const value = text.trim() if (value.length === 0) return dispatch({ type: 'add', text: value }) setText('') } return ( <form onSubmit={submit} style={{ display: 'flex', gap: '0.5rem' }}> <input value={text} onChange={e => setText(e.target.value)} placeholder='Nueva tarea' /> <button type='submit'>Añadir</button> </form> ) } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// src/components/todo/TodoItem.tsx import React from 'react' import { useTodos } from '../../context/TodoContext' import type { Todo } from '../../types/todo' export function TodoItem({ todo }: { todo: Todo }) { const { dispatch } = useTodos() return ( <li style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <input type='checkbox' checked={todo.done} onChange={() => dispatch({ type: 'toggle', id: todo.id })} /> <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>{todo.text}</span> <button type='button' onClick={() => dispatch({ type: 'remove', id: todo.id })} aria-label='Eliminar'> × </button> </li> ) } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// src/components/todo/TodoList.tsx import React from 'react' import { useTodos } from '../../context/TodoContext' import { TodoItem } from './TodoItem' import type { Filter } from '../../types/todo' export function TodoList({ filter }: { filter: Filter }) { const { state } = useTodos() const items = React.useMemo(() => { switch (filter) { case 'active': return state.filter(t => !t.done) case 'completed': return state.filter(t => t.done) default: return state } }, [state, filter]) if (items.length === 0) return <p>Sin tareas</p> return ( <ul style={{ paddingLeft: 0, listStyle: 'none' }}> {items.map(t => ( <TodoItem key={t.id} todo={t} />) )} </ul> ) } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// src/components/todo/TodoFilter.tsx import React from 'react' import type { Filter } from '../../types/todo' interface Props { value: Filter onChange: (f: Filter) => void } export function TodoFilter({ value, onChange }: Props) { const btn = (f: Filter) => ( <button type='button' onClick={() => onChange(f)} disabled={value === f}> {f} </button> ) return ( <div style={{ display: 'flex', gap: '0.5rem' }}> {btn('all')} {btn('active')} {btn('completed')} </div> ) } |
6.4 Integración en App
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// src/App.tsx import React from 'react' import { TodoProvider } from './context/TodoContext' import { TodoInput } from './components/todo/TodoInput' import { TodoList } from './components/todo/TodoList' import { TodoFilter } from './components/todo/TodoFilter' import type { Filter } from './types/todo' export default function App() { const [filter, setFilter] = React.useState<Filter>('all') return ( <TodoProvider> <main style={{ maxWidth: 640, margin: '2rem auto', padding: '0 1rem' }}> <h1>Interfaces interactivas React con TypeScript</h1> <TodoInput /> <section style={{ marginTop: '1rem' }}> <TodoFilter value={filter} onChange={setFilter} /> <TodoList filter={filter} /> </section> </main> </TodoProvider> ) } |
Con este flujo, tienes un ejemplo práctico de desarrollo frontend con TypeScript utilizando estado global sencillo, componentes desglosados y comunicación entre ellos.
Paso 7: Optimización, accesibilidad y buenas prácticas
7.1 Memorización selectiva
- useCallback para callbacks estables en componentes que dependen de referencias.
- useMemo para cálculos costosos o listas filtradas derivadas.
- React.memo para evitar renders innecesarios en componentes puros.
Ejemplo:
1 2 3 |
const filtered = React.useMemo(() => heavyFilter(data), [data]) const onSelect = React.useCallback((id: string) => setSelected(id), []) |
Evita memorización prematura; enfócate en rutas críticas de rendimiento.
7.2 Tipado estricto
- Activa strict en tsconfig para mayor seguridad.
- Prefiere tipos específicos en vez de any.
- Usa tipos utilitarios (Partial, Pick, Omit, ReturnType) cuando ayuden a expresar la intención.
7.3 Estructura y patrones de componentes
- Mantén componentes pequeños y enfocados en una responsabilidad.
- Prefiere composición sobre herencia.
- Centraliza tipos compartidos en src/types para evitar duplicación.
7.4 Accesibilidad (a11y)
- Usa etiquetas semánticas (main, header, nav, button, etc.).
- Añade aria-* cuando sea necesario, con valores descriptivos.
- Asegura contraste de color y foco visible.
7.5 Manejo de errores y estados vacíos
- Muestra mensajes claros en errores de carga y listas vacías.
- Implementa límites de error (Error Boundary) para capturar fallos de render.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// src/components/common/ErrorBoundary.tsx import React from 'react' interface State { hasError: boolean } export class ErrorBoundary extends React.Component<React.PropsWithChildren, State> { state: State = { hasError: false } static getDerivedStateFromError() { return { hasError: true } } componentDidCatch(err: unknown) { console.error('Error capturado', err) } render() { if (this.state.hasError) return <p>Ha ocurrido un error.</p> return this.props.children } } |
7.6 Fetch y tipos robustos
- Valida datos de APIs si es posible (zod/io-ts) para garantizar conformidad con tipos.
- Maneja abortos de solicitud al desmontar componentes.
7.7 Tests unitarios y de integración
- Usa Vitest + React Testing Library con TypeScript.
- Testea lógica de reducers y hooks personalizados.
Errores comunes y cómo evitarlos
- No tipar props y events: añade tipos a eventos, callbacks y estados.
- Abusar de any: prefiere unknown y refina mediante type guards.
- Prop drilling excesivo: adopta Context o un store ligero cuando corresponda.
- Keys inestables en listas: usa ids únicos consistentes.
- Efectos sin limpieza: usa AbortController y funciones de cleanup en useEffect.
SEO técnico para proyectos React TypeScript
- Server-Side Rendering (SSR) o Static Site Generation (SSG) con frameworks como Next.js si necesitas indexación rápida.
- Metadatos y títulos dinámicos con react-helmet-async o la API de metadatos en SSR.
- Código dividido por rutas (code splitting) para mejorar métricas Web Vitals.
Conclusión
Has completado un recorrido práctico y profesional para construir interfaces interactivas React con TypeScript: configuraste el entorno con Vite, creaste componentes funcionales, administraste estado con hooks, tipaste de forma segura y estableciste comunicación entre componentes usando props, callbacks y Context API.
Siguiente paso: crea una pequeña funcionalidad propia (por ejemplo, un buscador de productos con filtros y paginación) aplicando estos patrones. Comparte tu avance, refactoriza con confianza y continúa ampliando tu toolkit con patrones avanzados (formularios con React Hook Form, optimizaciones con Suspense y useTransition, e internacionalización).
¿Listo para llevar tu desarrollo frontend con TypeScript al siguiente nivel? Guarda este tutorial React TypeScript, crea tu repositorio de inicio y comienza a iterar. ¡Construye hoy la próxima interfaz interactiva que sorprenda a tus usuarios!