React Query: una guía (muy) práctica

React Query: una guía (muy) práctica

El siguiente artículo pretende dar un overview general de qué es React Query, sus ventajas y algunos consejos valiosos para tu día a día.

React es un framework para construir interfaces que no viene con un set de herramientas fijas de desarrollo, lo cual obliga al desarrollador a complementarlo con otras herramientas que agilizan la labor.

Durante el desarrollo, uno de los más grandes desafíos es traer datos del servidor y ponerlos a disposición de toda nuestra aplicación. Esto implica:

  • Manejar el estado de la petición.
  • Poner toda la data en el estado de React.
  • Actualizarse constantemente con los datos del servidor.
  • Hacer abstracciones para no repetir lógica.

Una de las librerías que soluciona gran parte de estos problemas es React Apollo, la cual no solo realiza las peticiones al servidor, sino que trae consigo una caché para guardar estos datos y consultarlos después.

Lo que más sorprende de esta librería es que además de incluir los datos del servidor, también pone esta data accesible para que toda la aplicación los pueda usar, pero bueno, todo eso es posible si trabajas con Graphql. ¿Y qué pasa si trabajas con REST? Aquí es donde entra React Query.

Introducción a React Query

React Query toma mucho de lo bueno de React Apollo, con la diferencia de que tú defines la capa donde haces petición al server. React Query solo toma una promise y guarda el resultado de esta en caché, además de incluir el manejo de errores y del estado de la promise.

Si todavía no logras ver la ventaja de todo lo anterior, déjame poner un ejemplo  en el que trabajaremos con una lista de contactos, la cual viene de un servidor. La abordaremos con y sin React Query.

Ejemplo sin React Query

import baseApi from "../lib/baseApi";
import { useEffect, useState } from "react";
import { IContact } from "../types";

export const WithoutReactQuery = () => {
  const [contacts, setContacts] = useState<IContact[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<{ message: string } | null>(null);
  useEffect(() => {
    setLoading(true);
    baseApi
      .get("/contacts")
      .then((result) => result.data)
      .then((data) => {
        setContacts(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err);
        setLoading(false);
      });
  }, []);

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  return (
    <div>
      {loading ? (
        <h3>loading...</h3>
      ) : (
        <ul>
          {contacts.map((contact) => (
            <li key={contact.id}>
              {contact.name} | {contact.lastName}
            </li>
          ))}
        </ul>
      )}
    </div>
  );

Bien, este es un escenario típico donde realizamos una petición hacia nuestra API y nos traemos los contactos. Todo el proceso de traer los datos y ponerlos en el estado de React, manejar el error y mostrar un estado de carga, nos ha tomado unas 18 líneas más o menos. A la larga, código como éste es difícil de mantener y se hace muy repetitivo.

Ejemplo con React Query

Para utilizar React Query, necesitamos iniciarlo. Sigue los pasos de la siguiente imagen:

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/*rest of code*/}
    </QueryClientProvider>
  );
}

Este es el setup básico que necesita React Query para funcionar. Entraremos en detalle más adelante sobre la configuración que podemos aplicar.

Bien, ahora volvamos al ejemplo de hace un momento.

export const WithReactQuery = () => {
  const queryInfo = useQuery(["contacts"], () => {
    return baseApi.get<IContact[]>("/contacts").then((d) => d.data);
  });

  if (queryInfo.isError) {
    return <div>Error: {(queryInfo.error as any).message}</div>;
  }

  return (
    <div>
      {queryInfo.isLoading ? (
        <h3>loading...</h3>
      ) : (
        <ul>
          {queryInfo.data.map((contact) => (
            <li key={contact.id}>
              {contact.name} | {contact.lastName}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

Mucho menos código que el anterior, ¿verdad :)?

Quizás estés un poco perdida/o con lo que acabamos de ver. ¡No te preocupes! Paso a paso.

Queries y Mutations

React Query plantea dos formar de realizar operaciones, las cuales son Queries y Mutations.

Si tocaste Graphql anteriormente, quizás te resulte familiar este concepto, pero de todas maneras repasemos.

Queries:

Cualquier operación que solo implique lectura de datos desde el servidor, en un método REST, estaríamos hablando de GET.

En el ejemplo anterior hicimos una query para traernos los contactos.

const queryInfo = useQuery(["contacts"], () => {
  return baseApi.get<IContact[]>("/contacts").then((d) => d.data);
});

El primer parámetro pasado al hook es un array el cual actúa como un conjunto de keys para identificar este resultado en caché. Este puede ser dinámico. Por ejemplo, si agregáramos un filtro, nuestro código ser vería así:

export const WithReactQuery = () => {
  const [name, setName] = useState("");
  const queryInfo = useQuery(["contacts", name], async () => {
    const params = new URLSearchParams();
    params.set("name", name);
    return baseApi
      .get<IContact[]>("/contacts", {
        params: params,
      })
      .then((d) => d.data);
  });
// rest of code

Esto quiere decir que para cada petición con un nombre diferente, tendremos una key distinta y, por ende, datos diferentes.

Mutations

Cualquier operación que implique una modificación en los datos del servidor, en REST sería POST, DELETE, PUT.

A diferencia de una query, el resultado de una mutación no es guardado en caché.

Ahora veamos la manera de guardar un contacto. Para realizar una mutación React Query, exporta useMutation.

export const SaveContactComponent = () => {
  const saveContactMutation = useMutation({
    mutationFn: async (input: IContactInput) => {
      return baseApi.post("/contact", input);
    },
  });
  return (
    <div
      className={`
    w-screen h-screen flex justify-center items-center bg-blue-600
    `}
    >
      {saveContactMutation.isLoading && <div>Saving..</div>}
      <form
        className="flex flex-col gap-3"
        onSubmit={(e) => {
          e.preventDefault();
          saveContactMutation.mutate(values);
        }}
      >
        {/* inputs */}
        <button
          className="bg-violet-400 rounded-md p-2 text-white"
          type="submit"
        >
          Submit
        </button>
      </form>
    </div>
  );
};

Como dijimos anteriormente, una mutación es un cambio en el servidor. Por ejemplo, aquí agregamos un contacto, lo que implica que el listado de contactos cambiará. Ahora bien, ¿cómo actualizamos la lista de contactos?

Invalidar Queries

Cada petición está relacionada con una key específica y eso implica que, cuando se haga una petición, React Query primero preguntará si existe esta key en caché. De existir, la recuperará rápidamente, pero también podemos decirle a React Query que estos datos ya no son válidos y que los traiga nuevamente desde el servidor.

Dicho lo anterior, el hook useMutation quedaría así:

// rest of code
const saveContactMutation = useMutation({
  mutationFn: async (input: IContactInput) => {
    const result = baseApi.post("/contact", input);
    await queryClient.invalidateQueries({
      queryKey: ["contacts"],
    });
    return result;
  },
});

Con lo anterior, actualizaremos todas las queries que tengan contacts como key.

Esto también actualiza las queries con el filtro name. Si éste no es el comportamiento que deseas, puedes agregar el flag exact.

Si deseas un poco más de información sobre esto, acude a la documentación.

Puntos clave

Ahora que ya hemos visto un poco acerca del funcionamiento básico de React Query, veamos ciertos puntos que nos pueden ayudar a utilizar mejor esta librería.

Utilizar custom hooks

Para evitar hacer repetidas llamadas al servidor, normalmente elevamos estas peticiones para que sean accesibles desde los componentes hijos. React Query cambia un poco nuestra forma de pensar, ya que no debemos preocuparnos si nuestras llamadas se realizarán múltiples veces, dado que todo esto depende de la key.

Veamos un ejemplo:

export const Parent = ({ children }) => {
  const queryInfo = useQuery(["contacts"], async () => {
    return baseApi.get<IContact[]>("/contacts").then((d) => d.data);
  });

  return <div>{children}</div>;
};

export const Child = () => {
  const queryInfo = useQuery(["contacts"], async () => {
    return baseApi.get<IContact[]>("/contacts").then((d) => d.data);
  });

  return <div></div>;
};

export const App = () => {
  return (
    <div>
      <Parent>
        <Child />
      </Parent>
    </div>
  );
};

En el código anterior, el componente Child tiene una query con la key contacts y el componente Parent también, entonces pasará lo siguiente:

  • El componente Parent se suscribe a la data por primera vez. Como react-query no tiene en cache estos datos, realiza la petición al server.
  • El componente Child se subscribe a la data por segunda vez. Si existe la data en cache,  ya no tendremos una segunda llamada al server.

Dicho esto, podemos utilizar Custom Hooks para facilitar el acceso a estos datos.

export const useContacts = () => {
  return useQuery(["contacts"], async () => {
    return baseApi.get<IContact[]>("/contacts").then((d) => d.data);
  });
};

export const Parent = ({ children }) => {
  const queryInfo = useContacts();

  return <div>{children}</div>;
};

export const Child = () => {
  const queryInfo = useContacts();
  return <div></div>;
};

export const App = () => {
  return (
    <div>
      <Parent>
        <Child />
      </Parent>
    </div>
  );
};

Así hemos logrado reutilizar código y, si tenemos que hacer cambios en esta petición, la hacemos en un solo lugar.

Diferencia entre StaleTime y CacheTime

React Query basa su lógica de caché en stale-while-revalidate. Este tema es un poco amplio, pero básicamente consiste en actualizar los datos por detrás (hacer la petición al servidor) y validarlos contra la caché actual. Si esto cambia, actualiza la UI, mientras que de lo contrario no realizará ninguna acción. Por todo lo anterior,  brindamos una mejor experiencia de usuario.

Pero ¿a qué viene esto?

React Query nos brinda la posibilidad de cambiar el comportamiento de la caché. Veamos algunos conceptos:

StaleTime: Se refiere a la duración entre que los datos se considera frescos hasta que pasan a obsoletos. Si los datos en caché están frescos, no se hace una validación al servidor para verificar si éstos son correctos o no.

Por defecto, este valor es 0. En el ejemplo que abordamos, cada vez que una query se vuelve a llamar, casi siempre se hace una llamada al servidor. Recuerdo que cuando yo estudié esto, pensé que lo anterior arruina el sentido de React Query porque no nos estaría ahorrando llamadas, pero estaba equivocado: la llamada que hacen hacia el server es para revalidar. Si React Query comprueba que nuestra caché es correcta, pasará desapercibida para el usuario esta petición.

CacheTime: Es el tiempo que transcurre mientras las queries inactivas son eliminadas completamente de caché. Por defecto son 5 minutos.

Hasta el momento no he tenido la necesidad de modificar esta propiedad porque, por ejemplo, si no tienes ningún componente observando la lista de contactos durante 5 minutos, significa que esto no se muestra en ninguna parte. Entonces, ¿para qué tener esto en caché?

Con esto en cuenta, podemos hacer esta configuración directamente en el hook...

export const useContacts = () => {
 return useQuery(
    ["contacts"],
    async () => {
      return baseApi.get<IContact[]>("/contacts").then((d) => d.data);
    },
    {
      staleTime: 1000,
    }
  );
};
};

...o directamente en la instancia del cliente, considerando que esto sería una configuración general.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000,
    },
  },
});

Queries dependientes

Seguramente en algún momento tu query tendrá una dependencia con algo en el exterior. Digo esto porque todas las consultas se hacen en cuanto el componente se monta en la pantalla, pero podemos cambiar esto.

Supongamos que ahora quieres mostrar la lista de contactos, pero con un filtro en específico y éste no está presente al montar el componente. Aquí es donde entra en juego la propiedad enabled.

const App = () => {
  const queryInfo = useQuery(
    ["contacts", name],
    async () => {
      const params = new URLSearchParams();
      params.set("name", name);
      return baseApi
        .get<IContact[]>("/contacts", {
          params: params,
        })
        .then((d) => d.data);
    },
    {
      enabled: !!name && name.length > 0,
    }
  );

  return <div></div>;
};

Al agregar enabled: !!name && name.length > 0 le decimos a esta consulta que no debe ejecutarse hasta que el nombre exista y su tamaño sea a mayor a cero.

Select Property

Si has usado Redux anteriormente, de seguro React Query te fascina. Aquí conviene preguntas: ¿Qué hay de los selectores?

No siempre tenemos los filtros en el servidor: en algún momento tenemos que filtrar esto en el cliente.

Dentro la opción de una query encontramos Select, el cual nos permite hacer algo con el resultado de la función que pasamos a queryFn y además suscribirnos solo al cambio de esa parte de la data en específico.

Si quisiéramos hacer un filtro en el cliente por nombre tendríamos lo siguiente:

const App = () => {
  const [name, setName] = useState("");
  const queryInfo = useQuery(
    ["contacts"],
    async () => {
      const params = new URLSearchParams();
      params.set("name", name);
      return baseApi.get<IContact[]>("/contacts").then((d) => d.data);
    },
    {
      select: (data) => data.filter((d) => d.name.indexOf(name) !== -1),
    }
  );

  return (
    <div>
      <input
        value={name}
        onChange={(e) => {
          setName(e.target.value);
        }}
      />
    </div>
  );
};

De esta manera, filtramos el resultado y no necesariamente hemos hecho una llamada al servidor. Es recomendable usar useCallback para memorizar la función.

Client State Vs Server State

En algún momento puede surgir una pregunta como "¿ya no necesito Redux?". Quizás alguna similar.

Server State

React Query es Server State y te ayudará a mantenerse sincronizado/a con tu servidor y reducirá la implementación de la integración. De este modo, React Query se encargará de todo lo que tengas relacionado con servidor.

Client State

Para manejar el estado del cliente, puedes utilizar las features de React o librerías como Jotai o Zuztand.

Si piensas utilizar Redux junto con React-Query, no lo recomiendo en absoluto. De hecho, Redux tiene RTk, un paquete complementario a Redux basado en React-Query.

Espero que este artículo te motive a usar esta librería y empieces a usarla en tus proyectos. Traté de abordar todos los conceptos básicos y algunos que son muy útiles a la hora de la práctica. Después de ver el gran potencial de esta librería, puedes seguir con la documentación.

Dejo algunos keywords que te pueden ayudar a seguir buscando información.

  • Infinite Queries.
  • Optimistic Updates.
  • SSR with React Query.

¡Happy Code!

⚠️
Las opiniones y comentarios emitidos en este artículo son propiedad única de su autor y no necesariamente representan el punto de vista de Revelo.

Revelo Content Network da la bienvenida a todas las razas, etnias, nacionalidades, credos, géneros, orientaciones, puntos de vista e ideologías, siempre y cuando promuevan la diversidad, la equidad, la inclusión y el crecimiento profesional de los profesionales en tecnología.