Saltar al contenido principal
Tecnología

Data-Fetching usando React Hooks

6 min de lectura
Data-Fetching usando React Hooks

La obtención de datos es una tarea común que normalmente realizamos al construir un componente. En este post vamos a ver cómo convertir un componente basado en clases a uno funcional, que utiliza hooks para el fetching de datos.

El caso de uso

Supongamos que estamos construyendo un componente PostList, que obtiene datos de una API y maneja los siguientes estados:

  • Estado vacío
  • Estado de error
  • Estado de carga

El código de nuestro componente PostList sería el siguiente:

// PostList.js
import 'whatwg-fetch';
import 'abortcontroller-polyfill';
import React from 'react';
import Post from '@components/Post';
import Container from '@components/Container';

export default class PostList extends React.Component {
  static displayName = 'PostList';

  static defaultProps = {
    url: 'https://jsonplaceholder.typicode.com/posts',
  };

  // Crear una instancia de AbortController
  abortController = new AbortController();

  state = {
    isLoading: true,
    posts: [],
    error: null,
  };

  componentDidMount() {
    this.makeFetchHappen(this.props.url);
  }

  componentDidUpdate(prevProps) {
    if (this.props.url !== prevProps.url) {
      this.makeFetchHappen(this.props.url);
    }
  }

  componentWillUnmount() {
    if (this.abortController) {
      // Cancelar la request cuando el componente se desmonta
      this.abortController.abort();
      this.abortController = null;
    }
  }

  render() {
    const { isLoading, posts, error } = this.state;

    return (
      <Container
        isLoading={isLoading}
        isError={!isLoading && error}
        isEmpty={!isLoading && posts && posts.length === 0}
      >
        {posts.map((post, idx) => (
          <Post key={`Post.${idx}`} {...post} />
        ))}
      </Container>
    );
  }

  makeFetchHappen = async url => {
    try {
      if (!url) {
        throw new Error(`'url' is required for data-fetching`);
      }

      const { signal } = this.abortController;

      // Configurar cancelación pasando el signal de la instancia de AbortController
      const response = await fetch(url, { signal });
      const posts = await response.json();

      this.setState({ posts });
    } catch (error) {
      this.setState({ error });
    } finally {
      this.setState({ isLoading: false });
    }
  };
}

Como podés ver, el componente inicializa su propio estado para manejar las diferentes fases del renderizado. El requerimiento de data-fetching se cumple durante componentDidMount, y en caso de que url cambie cuando se pasa como prop a PostList, también definimos el callback componentDidUpdate, que va a reintentar la obtención de datos.

El problema

Cuando trabajás en aplicaciones reales, no solés depender de una sola llamada de data-fetching — vas a tener múltiples. Además, varios componentes van a estar obteniendo datos para cumplir con sus propios requerimientos.

Nuestra solución actual está atada a un solo caso de uso específico. Si queremos trasladar esto a otro caso de uso, como obtener un post por su id, vamos a tener que crear otro componente y replicar parte de la lógica de data-fetching que debería estar en otro lugar, no en el componente en sí.

La solución parcial

Al encontrarse con este problema, la solución más común es crear un método genérico de fetch y crear un servicio, como en este ejemplo:

// services.js
import 'whatwg-fetch';
import 'abortcontroller-polyfill';

const API_URL = 'https://jsonplaceholder.typicode.com';

const doFetch = async (url, options) => {
  try {
    if (!url) {
      throw new Error(`'url' is required for data-fetching`);
    }

    const response = await fetch(url, options);
    const data = await response.json();

    return [false, data, null];
  } catch (error) {
    return [false, null, error];
  }
};

export const getPosts = (ctx) => {
  return doFetch(`${API_URL}/posts`, ctx);
};

export const getPostById = (ctx) => {
  return doFetch(`${API_URL}/${ctx.id}`, ctx);
};

Y luego, reescribirías tu componente PostList de la siguiente manera:

import React from 'react';
import Post from '@components/Post';
import Container from '@components/Container';
import { getPosts } from '@services';

export default class PostList extends React.Component {
  static displayName = 'PostList';

  // Crear una instancia de AbortController
  abortController = new AbortController();

  state = {
    isLoading: true,
    posts: [],
    error: null,
  };

  componentDidMount() {
    this.makeFetchHappen({ signal: this.abortController.signal });
  }

  componentWillUnmount() {
    if (this.abortController) {
      // Cancelar la request cuando el componente se desmonta
      this.abortController.abort();
      this.abortController = null;
    }
  }

  render() {
    const { isLoading, posts, error } = this.state;

    return (
      <Container
        isLoading={isLoading}
        isError={!isLoading && error}
        isEmpty={!isLoading && posts && posts.length === 0}
      >
        {posts.map((post, idx) => (
          <Post key={`Post.${idx}`} {...post} />
        ))}
      </Container>
    );
  }

  makeFetchHappen = async ctx => {
    const [isLoading, posts, error] = await getPosts(ctx);

    this.setState({
      isLoading,
      posts,
      error,
    });
  };
}

Pero, ¿por qué una solución parcial?

Como podés ver arriba, el desarrollador que trabaje con este código va a necesitar pensar en cosas relacionadas al data-fetching y al estado del componente, como:

  • Implementar la cancelación de requests
  • Acoplarse a los callbacks del ciclo de vida de React
  • Actualizar el estado basándose en los datos que vienen de la llamada al servicio

Es una solución parcial porque nos va a seguir obligando a pensar y trabajar en cosas que podríamos resolver mal.

La solución final

Una mejor solución a este problema llegó con React v16.8. Usar React Hooks para data-fetching te permite olvidarte de cosas como las que mencioné antes.

Podés crear un custom hook para data-fetching, como el siguiente:

// useFetch.js
import { useEffect, useRef, useState } from 'react';

const log = (...args) => console.warn(...args);

const useFetch = (url, options) => {
  const abortControllerRef = useRef(null);

  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);
  const [response, setResponse] = useState(null);

  useEffect(() => {
    abortControllerRef.current = new AbortController();
  });

  useEffect(() => {
    const { signal } = abortControllerRef.current;

    const makeFetchHappen = async () => {
      try {
        if (!url) {
          throw new Error(`'url' is required for fetching data`);
        }

        const response = await fetch(url, { ...options, signal });

        let data;

        try {
          data = await response.json();
        } catch (error) {
          log(`useFetch: can't parse JSON, trying parsing response as text`);
          data = await response.text();
        } finally {
          setResponse(data);
        }
      } catch (error) {
        log(`useFetch: ${error.message}`);
        setError(error);
      } finally {
        setLoading(false);
      }
    };

    makeFetchHappen();

    return () => abortControllerRef.current.abort();
  }, [url, options]);

  return [loading, response, error];
};

export default useFetch;

Y luego, convertirías tu componente basado en clases a uno funcional:

// PostList.js
import 'whatwg-fetch';
import 'abortcontroller-polyfill';
import React from 'react';
import Post from '@components/Post';
import Container from '@components/Container';
import useFetch from '@hooks/useFetch';

const PostList = props => {
  const [isLoading, posts, error] = useFetch(
    'https://jsonplaceholder.typicode.com/posts'
  );

  return (
    <Container
      isLoading={isLoading}
      isError={!isLoading && error}
      isEmpty={!isLoading && posts && posts.length === 0}
    >
      {posts &&
        posts.map((post, idx) => <Post key={`Post.${idx}`} {...post} />)}
    </Container>
  );
};

PostList.displayName = 'PostList';

export default PostList;

Como podés ver, abstrajimos todo en nuestro custom hook useFetch. Nuestro hook personalizado se va a encargar de:

  • Manejar los diferentes estados para nuestros requerimientos de datos
  • Obtener datos en componentDidMount / componentDidUpdate
  • Manejar la cancelación de requests cuando el componente que usa este hook se desmonta

Podríamos aumentar la complejidad de este hook agregando soporte para:

  • Manejar queries de GraphQL
  • Cachear resultados de una API para actualizaciones más rápidas
  • Parsear diferentes tipos de datos al llamar a una API
  • Hacer que la obtención de datos sea opcional en el ciclo de vida de React vía useEffect

Pero para los fines educativos de este post no vamos a hacer esos cambios.

Bueno, eso es todo, espero que lo hayas disfrutado. Si te gustó, dejá un comentario o reacción — me gustaría escuchar tu feedback.

Etiquetas

React Hooks Frontend