29. Visualización de datos en una lista
Tutorial: Visualización de Datos en una Lista en React Native
📋 Introducción
En este tutorial aprenderemos a mostrar datos obtenidos de una API en una lista interactiva. Usaremos la API de Rick and Morty para crear una pantalla que muestre personajes con imágenes, estados y detalles.
🎯 Objetivo
Crear una pantalla de personajes que:
Consuma datos desde una API
Muestre una lista de personajes
Implemente componentes reutilizables
Maneje estados de carga y error
Sea responsiva y con buen diseño
🏗️ Paso 1: Configuración del Proyecto
Estructura de archivos:
text
src/
├── api/
│ ├── RickAndMortyApi.ts
│ ├── RickAndMortyApiAlt.ts
│ └── entities/
│ ├── ApiInfoResponse.ts
│ └── CharactersResponse.ts
├── components/
│ └── characters/
│ ├── CharacterTile.tsx
│ ├── StatusIndicator.tsx
│ └── DataTile.tsx
├── screens/
│ ├── characters.tsx
│ └── profile.tsx
└── utils/
└── ViewUtils.ts
🔧 Paso 2: Configurar la API
RickAndMortyApiAlt.ts (con Axios):
typescript
import axios from 'axios';
import { CharactersResponse } from './entities/CharactersResponse';
export const getCharacters = () => {
return axios.get<CharactersResponse>(
'https://rickandmortyapi.com/api/character'
);
};
Entities/CharactersResponse.ts:
typescript
// Definir tipos para el status
export type Status = 'Alive' | 'Dead' | 'unknown';
// Interfaz para un personaje
export interface Character {
id: number;
name: string;
status: Status;
species: string;
type: string;
gender: string;
origin: {
name: string;
url: string;
};
location: {
name: string;
url: string;
};
image: string;
episode: string[];
url: string;
created: string;
}
// Interfaz para la respuesta de personajes
export interface CharactersResponse {
info: {
count: number;
pages: number;
next: string | null;
prev: string | null;
};
results: Character[];
}
🖥️ Paso 3: Pantalla Principal de Personajes
screens/characters.tsx:
typescript
import React, { useEffect, useState } from 'react';
import {
ScrollView,
StyleSheet,
View,
ViewStyle,
ActivityIndicator,
Text,
} from 'react-native';
import { getScale } from '../utils/ViewUtils';
import { getCharacters } from '../api/RickAndMortyApiAlt';
import { Character } from '../api/entities/CharactersResponse';
import { CharacterTile } from '../components/characters/CharacterTile';
export const Characters = () => {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const apiCall = async () => {
try {
setLoading(true);
const response = await getCharacters();
console.log('Character Info:', JSON.stringify(response.data.info));
setCharacters(response.data.results);
} catch (err) {
console.error('Error fetching characters:', err);
setError('Error al cargar los personajes');
} finally {
setLoading(false);
}
};
apiCall();
}, []);
if (loading) {
return (
<View style={styles.loaderContainer}>
<ActivityIndicator size="large" color="#0000ff" />
<Text style={styles.loaderText}>Cargando personajes...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
);
}
return (
<View style={styles.mainContainer}>
<ScrollView>
{characters.map((character) => (
<CharacterTile
key={character.id} // Key única para cada elemento
characterInfo={character}
/>
))}
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
mainContainer: {
flex: 1,
paddingHorizontal: getScale(12),
paddingVertical: getScale(12),
} as ViewStyle,
loaderContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
},
loaderText: {
marginTop: getScale(12),
fontSize: getScale(15),
fontWeight: '500',
color: '#333',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
padding: getScale(20),
},
errorText: {
fontSize: getScale(16),
color: 'red',
textAlign: 'center',
},
});
🧩 Paso 4: Componente Tile para Personajes
components/characters/CharacterTile.tsx:
typescript
import React from 'react';
import {
Image,
ImageStyle,
StyleSheet,
Text,
TextStyle,
View,
ViewStyle,
} from 'react-native';
import { Character } from '../../api/entities/CharactersResponse';
import { getScale } from '../../utils/ViewUtils';
import { StatusIndicator } from './StatusIndicator';
import { DataTile } from './DataTile';
export interface CharacterTileProperties {
characterInfo: Character;
}
export const CharacterTile = (props: CharacterTileProperties) => {
const { characterInfo } = props;
return (
<View style={styles.mainContainer}>
<Image
source={{ uri: characterInfo.image }}
style={styles.image}
/>
<View style={styles.contentContainer}>
<Text style={styles.name}>{characterInfo.name}</Text>
<View style={styles.statusContainer}>
<StatusIndicator status={characterInfo.status} />
<Text style={styles.statusText}>
{`${characterInfo.status} - ${characterInfo.species}`}
</Text>
</View>
<DataTile
title="Last known location:"
content={characterInfo.location.name}
/>
<DataTile
title="First seen in:"
content={characterInfo.origin.name}
/>
</View>
</View>
);
};
const styles = StyleSheet.create({
mainContainer: {
paddingVertical: getScale(8),
marginVertical: getScale(4),
width: '100%',
minHeight: getScale(100),
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: 'white',
borderRadius: getScale(8),
paddingHorizontal: getScale(12),
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
} as ViewStyle,
image: {
width: getScale(80),
height: getScale(80),
borderRadius: getScale(40),
marginRight: getScale(12),
} as ImageStyle,
contentContainer: {
flex: 1,
flexDirection: 'column',
},
name: {
fontSize: getScale(16),
fontWeight: 'bold',
color: '#333',
marginBottom: getScale(4),
} as TextStyle,
statusContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: getScale(8),
},
statusText: {
fontSize: getScale(12),
color: '#666',
marginLeft: getScale(4),
} as TextStyle,
});
🔴 Paso 5: Componente Indicador de Estado
components/characters/StatusIndicator.tsx:
typescript
import React from 'react';
import { View, ViewStyle, StyleSheet } from 'react-native';
import { getScale } from '../../utils/ViewUtils';
import { Status } from '../../api/entities/CharactersResponse';
interface StatusIndicatorProps {
status: Status;
}
export const StatusIndicator = (props: StatusIndicatorProps) => {
const { status } = props;
const getStatusColor = (): string => {
switch (status) {
case 'Alive':
return '#4CAF50'; // Verde
case 'Dead':
return '#F44336'; // Rojo
case 'unknown':
return '#9E9E9E'; // Gris
default:
return '#9E9E9E';
}
};
return (
<View
style={[
styles.indicator,
{ backgroundColor: getStatusColor() }
]}
/>
);
};
const styles = StyleSheet.create({
indicator: {
width: getScale(10),
height: getScale(10),
borderRadius: getScale(5),
} as ViewStyle,
});
📄 Paso 6: Componente Tile de Datos
components/characters/DataTile.tsx:
typescript
import React from 'react';
import { Text, TextStyle, View, ViewStyle, StyleSheet } from 'react-native';
import { getScale } from '../../utils/ViewUtils';
interface DataTileProps {
title: string;
content: string;
}
export const DataTile = (props: DataTileProps) => {
const { title, content } = props;
return (
<View style={styles.container}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.content}>{content}</Text>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: getScale(6),
} as ViewStyle,
title: {
fontSize: getScale(10),
fontWeight: '500',
color: '#999',
textTransform: 'uppercase',
marginBottom: getScale(2),
} as TextStyle,
content: {
fontSize: getScale(12),
fontWeight: '500',
color: '#333',
} as TextStyle,
});
🔧 Paso 7: Utilería para Dimensiones Relativas
utils/ViewUtils.ts:
typescript
import { Dimensions } from 'react-native';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const BASE_WIDTH = 380;
const SCALE_FACTOR = SCREEN_WIDTH / BASE_WIDTH;
/**
* Convierte valores a dimensiones relativas
*/
export const getScale = (pixels: number): number => {
return pixels * SCALE_FACTOR;
};
// Exportar dimensiones de pantalla para uso general
export const screenWidth = SCREEN_WIDTH;
export const screenHeight = Dimensions.get('window').height;
🚀 Paso 8: Actualizar App.tsx
App.tsx:
typescript
import React from 'react';
import { SafeAreaView } from 'react-native';
import { Characters } from './src/screens/characters';
function App(): JSX.Element {
return (
<SafeAreaView style={{ flex: 1 }}>
<Characters />
</SafeAreaView>
);
}
export default App;
⚡ Paso 9: Mejorar el Rendimiento con FlatList
Actualizar characters.tsx (versión con FlatList):
typescript
import React, { useEffect, useState } from 'react';
import {
FlatList,
StyleSheet,
View,
ViewStyle,
ActivityIndicator,
Text,
RefreshControl,
} from 'react-native';
import { getScale } from '../utils/ViewUtils';
import { getCharacters } from '../api/RickAndMortyApiAlt';
import { Character } from '../api/entities/CharactersResponse';
import { CharacterTile } from '../components/characters/CharacterTile';
export const Characters = () => {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState<boolean>(false);
const fetchCharacters = async () => {
try {
const response = await getCharacters();
setCharacters(response.data.results);
setError(null);
} catch (err) {
console.error('Error fetching characters:', err);
setError('Error al cargar los personajes');
}
};
const onRefresh = async () => {
setRefreshing(true);
await fetchCharacters();
setRefreshing(false);
};
useEffect(() => {
const loadData = async () => {
setLoading(true);
await fetchCharacters();
setLoading(false);
};
loadData();
}, []);
if (loading && !refreshing) {
return (
<View style={styles.loaderContainer}>
<ActivityIndicator size="large" color="#0000ff" />
<Text style={styles.loaderText}>Cargando personajes...</Text>
</View>
);
}
if (error && characters.length === 0) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
);
}
return (
<View style={styles.mainContainer}>
<FlatList
data={characters}
renderItem={({ item }) => (
<CharacterTile characterInfo={item} />
)}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={['#0000ff']}
tintColor="#0000ff"
/>
}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No hay personajes para mostrar</Text>
</View>
}
ListHeaderComponent={
error ? (
<View style={styles.errorBanner}>
<Text style={styles.errorBannerText}>{error}</Text>
</View>
) : null
}
initialNumToRender={10} // Solo renderiza 10 inicialmente
maxToRenderPerBatch={5} // Renderiza en lotes de 5
windowSize={5} // Mantiene 5 ventanas en memoria
/>
</View>
);
};
const styles = StyleSheet.create({
mainContainer: {
flex: 1,
backgroundColor: '#f5f5f5',
} as ViewStyle,
listContent: {
paddingHorizontal: getScale(12),
paddingVertical: getScale(12),
},
loaderContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
},
loaderText: {
marginTop: getScale(12),
fontSize: getScale(15),
fontWeight: '500',
color: '#333',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
padding: getScale(20),
},
errorText: {
fontSize: getScale(16),
color: 'red',
textAlign: 'center',
},
errorBanner: {
backgroundColor: '#FFEBEE',
padding: getScale(12),
borderRadius: getScale(8),
marginBottom: getScale(12),
},
errorBannerText: {
color: '#C62828',
fontSize: getScale(14),
textAlign: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: getScale(40),
},
emptyText: {
fontSize: getScale(16),
color: '#666',
textAlign: 'center',
},
});
📊 Comparación: ScrollView vs FlatList
ScrollView:
typescript
// ❌ Problemas:
// 1. Renderiza TODOS los elementos inmediatamente
// 2. Consumo alto de memoria con listas grandes
// 3. No reutiliza componentes
<ScrollView>
{characters.map(character => (
<CharacterTile key={character.id} characterInfo={character} />
))}
</ScrollView>
FlatList:
typescript
// ✅ Ventajas:
// 1. Renderizado perezoso (solo lo visible)
// 2. Reutilización de componentes
// 3. Optimizado para grandes listas
// 4. Soporte para pull-to-refresh
// 5. Separadores, headers, footers
<FlatList
data={characters}
renderItem={({ item }) => <CharacterTile characterInfo={item} />}
keyExtractor={(item) => item.id.toString()}
initialNumToRender={10}
/>
🎨 Paso 10: Agregar Separadores y Mejoras Visuales
Agregar separador entre items:
typescript
// En characters.tsx, agregar ItemSeparatorComponent
<FlatList
// ... otras props
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
// En styles:
separator: {
height: getScale(1),
backgroundColor: '#e0e0e0',
marginVertical: getScale(4),
} as ViewStyle,
Agregar header con contador:
typescript
const renderHeader = () => (
<View style={styles.header}>
<Text style={styles.headerTitle}>Personajes de Rick and Morty</Text>
<Text style={styles.headerCount}>
{characters.length} personajes cargados
</Text>
</View>
);
// En FlatList:
ListHeaderComponent={renderHeader}
💡 Mejores Prácticas para Listas
1. Keys únicas:
typescript
// Siempre usar keyExtractor
keyExtractor={(item) => item.id.toString()}
2. Evitar funciones inline en renderItem:
typescript
// ❌ EVITAR (crea nueva función en cada render)
renderItem={({ item }) => <CharacterTile characterInfo={item} />}
// ✅ PREFERIR (función memoizada)
const renderCharacter = useCallback(
({ item }: { item: Character }) => (
<CharacterTile characterInfo={item} />
),
[]
);
3. Virtualización con react-native-windowsize:
typescript
// Para listas muy grandes
import { useWindowDimensions } from 'react-native';
const { height } = useWindowDimensions();
const itemHeight = getScale(100);
const windowSize = Math.ceil(height / itemHeight) + 2;
4. Memoizar componentes:
typescript
import React, { memo } from 'react';
export const CharacterTile = memo((props: CharacterTileProperties) => {
// ... contenido del componente
});
🚀 Optimizaciones Avanzadas
1. Paginación infinita:
typescript
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loadMoreCharacters = async () => {
if (!hasMore || loadingMore) return;
setLoadingMore(true);
const nextPage = page + 1;
const response = await getCharacters(nextPage);
if (response.data.results.length > 0) {
setCharacters(prev => [...prev, ...response.data.results]);
setPage(nextPage);
setHasMore(!!response.data.info.next);
}
setLoadingMore(false);
};
// En FlatList:
onEndReached={loadMoreCharacters}
onEndReachedThreshold={0.5}
ListFooterComponent={loadingMore ? <ActivityIndicator /> : null}
2. Cache con react-query:
typescript
import { useQuery } from 'react-query';
const { data, isLoading, error } = useQuery(
'characters',
() => getCharacters().then(res => res.data),
{
staleTime: 5 * 60 * 1000, // 5 minutos
cacheTime: 10 * 60 * 1000, // 10 minutos
}
);
📱 Paso 11: Prueba Final
Resultados esperados:
✅ Lista de personajes cargada desde API
✅ Imágenes circulares con bordes redondeados
✅ Indicadores de estado (verde, rojo, gris)
✅ Información de ubicación y origen
✅ Scroll suave y responsivo
✅ Manejo de estados de carga y error
✅ Pull-to-refresh funcional
✅ Optimizado para performance
🎓 Resumen
Lo que aprendiste:
✅ Consumir API y mostrar datos en lista
✅ Crear componentes reutilizables (Tile, Indicator, DataTile)
✅ Implementar FlatList para mejor performance
✅ Manejar estados de carga y error
✅ Agregar pull-to-refresh
✅ Usar keys únicas para elementos de lista
✅ Dimensiones relativas para responsividad
Siguientes pasos:
Implementar búsqueda en la lista
Agregar filtros por status, género, especie
Implementar paginación infinita
Agregar animaciones al cargar
Cachear imágenes con react-native-fast-image
Compartir componente entre dispositivos
📚 Recursos Adicionales
Documentación oficial:
Librerías útiles:
react-query - Para cache y estado de servidor
react-native-skeleton-placeholder - Para placeholders de carga
¡Ahora tienes una lista funcional y optimizada de personajes!
Comentarios
Publicar un comentario