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:

  1. Consuma datos desde una API

  2. Muestre una lista de personajes

  3. Implemente componentes reutilizables

  4. Maneje estados de carga y error

  5. 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:

  1. ✅ Lista de personajes cargada desde API

  2. ✅ Imágenes circulares con bordes redondeados

  3. ✅ Indicadores de estado (verde, rojo, gris)

  4. ✅ Información de ubicación y origen

  5. ✅ Scroll suave y responsivo

  6. ✅ Manejo de estados de carga y error

  7. ✅ Pull-to-refresh funcional

  8. ✅ 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:

  1. Implementar búsqueda en la lista

  2. Agregar filtros por status, género, especie

  3. Implementar paginación infinita

  4. Agregar animaciones al cargar

  5. Cachear imágenes con react-native-fast-image

  6. Compartir componente entre dispositivos


📚 Recursos Adicionales

Documentación oficial:

Librerías útiles:

¡Ahora tienes una lista funcional y optimizada de personajes!


Comentarios

Entradas más populares de este blog

Guía Paso a Paso para Entender React Native (antes del Tutorial)

Tutorial: Aplicación React Native para Agregar Tareas - Minimalista

5. Vista Rapida: Estructura Projecto Base