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

17. Estructura de una aplicación de React Native, una vista mas profunda

12. Configuracion del entorno de desarrollo en Windows

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