Guía definitiva para dominar la gestión de datos asíncronos en React con la librería más popular.
React Query es una librería para:
npm install @tanstack/react-query
# Opcional: Devtools
npm install @tanstack/react-query-devtools
import React from 'react';
import ReactDOM from 'react-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
// Crear cliente de React Query
const queryClient = new QueryClient();
ReactDOM.render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
document.getElementById('root')
);
import { useQuery } from '@tanstack/react-query';
const fetchPosts = async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
return response.json();
};
function Posts() {
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
if (isLoading) return <div>Cargando...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
export default Posts;
Estado | Descripción | Propiedades Relacionadas |
---|---|---|
Loading | Primera carga de datos | isLoading , isFetching |
Error | Falló la petición | error , isError |
Success | Datos cargados correctamente | data , isSuccess |
Idle | Query deshabilitado | enabled: false |
queryKey
únicos para cada queryimport { useMutation, useQueryClient } from '@tanstack/react-query';
const addPost = async (newPost) => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
body: JSON.stringify(newPost),
});
return response.json();
};
function AddPost() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: addPost,
onSuccess: () => {
// Invalidar cache y refetch
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const newPost = {
title: formData.get('title'),
body: formData.get('body'),
};
mutation.mutate(newPost);
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Título" required />
<textarea name="body" placeholder="Contenido" required />
<button type="submit" disabled={mutation.isLoading}>
{mutation.isLoading ? 'Enviando...' : 'Crear Post'}
</button>
</form>
);
}
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
const fetchPosts = async ({ pageParam = 1 }) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${pageParam}&_limit=10`
);
return response.json();
};
function PaginatedPosts() {
const [page, setPage] = useState(1);
const { data, isLoading, isError } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts({ pageParam: page }),
keepPreviousData: true,
});
return (
<div>
{isLoading ? (
<div>Cargando...</div>
) : isError ? (
<div>Error al cargar posts</div>
) : (
<>
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<button
onClick={() => setPage(old => Math.max(old - 1, 1))}
disabled={page === 1}
>
Anterior
</button>
<span>Página {page}</span>
<button
onClick={() => setPage(old => old + 1)}
disabled={data.length < 10}
>
Siguiente
</button>
</>
)}
</div>
);
}
import { useInfiniteQuery } from '@tanstack/react-query';
const fetchPosts = async ({ pageParam = 1 }) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${pageParam}&_limit=10`
);
return response.json();
};
function InfinitePosts() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: ['infinitePosts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage, allPages) => {
return lastPage.length ? allPages.length + 1 : undefined;
},
});
return status === 'loading' ? (
<p>Cargando...</p>
) : status === 'error' ? (
<p>Error al cargar datos</p>
) : (
<>
<ul>
{data.pages.map((page, i) => (
<React.Fragment key={i}>
{page.map(post => (
<li key={post.id}>{post.title}</li>
))}
</React.Fragment>
))}
</ul>
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Cargando más...'
: hasNextPage
? 'Cargar más'
: 'No hay más datos'}
</button>
</>
);
}
import { useQueryClient } from '@tanstack/react-query';
function PostList() {
const queryClient = useQueryClient();
const prefetchPost = async (postId) => {
await queryClient.prefetchQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
});
};
return (
<ul>
{posts.map(post => (
<li
key={post.id}
onMouseEnter={() => prefetchPost(post.id)}
>
{post.title}
</li>
))}
</ul>
);
}
import { useMutation, useQueryClient } from '@tanstack/react-query';
function OptimisticTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancelar queries en curso
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot del valor anterior
const previousTodos = queryClient.getQueryData(['todos']);
// Actualización optimista
queryClient.setQueryData(['todos'], (old) =>
old.map(todo =>
todo.id === newTodo.id ? { ...todo, ...newTodo } : todo
)
);
// Retornar contexto con snapshot
return { previousTodos };
},
onError: (err, newTodo, context) => {
// Revertir al snapshot en caso de error
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
// Refetch para asegurar sincronización
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
const handleToggle = (todo) => {
mutation.mutate({
...todo,
completed: !todo.completed,
});
};
// Renderizar lista de todos...
}
import { useQuery } from '@tanstack/react-query';
const fetchPosts = async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
return response.json();
};
export function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 5 * 60 * 1000, // 5 minutos
});
}
import { usePosts } from '../hooks/usePosts';
function Posts() {
const { data, isLoading, error } = usePosts();
// Renderizar posts...
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutos
retry: 2, // Reintentar 2 veces antes de mostrar error
refetchOnWindowFocus: false,
},
mutations: {
onError: (error) => {
toast.error(error.message);
},
},
},
});
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Posts from '../Posts';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
test('muestra posts correctamente', async () => {
// Mock de fetch
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve([{ id: 1, title: 'Test Post' }]),
})
);
render(<Posts />, { wrapper });
expect(screen.getByText(/cargando/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Test Post')).toBeInTheDocument();
});
});
export interface Post {
id: number;
title: string;
body: string;
userId: number;
}
import { useQuery } from '@tanstack/react-query';
import { Post } from '../types/post';
const fetchPosts = async (): Promise<Post[]> => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
return response.json();
};
export function usePosts() {
return useQuery<Post[], Error>({
queryKey: ['posts'],
queryFn: fetchPosts,
});
}
import type { AppProps } from 'next/app';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient();
export default function App({ Component, pageProps }: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
import { create } from 'zustand';
import { useQuery } from '@tanstack/react-query';
interface PostsState {
posts: Post[];
setPosts: (posts: Post[]) => void;
}
const usePostsStore = create<PostsState>((set) => ({
posts: [],
setPosts: (posts) => set({ posts }),
}));
export const usePostsQuery = () => {
const { setPosts } = usePostsStore();
return useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
onSuccess: (data) => {
setPosts(data);
},
});
};
npm install graphql-request
import { useQuery } from '@tanstack/react-query';
import { request } from 'graphql-request';
const endpoint = 'https://api.graph.cool/simple/v1/cixos23120m0n0173veiiwrjr';
const fetchUsers = async () => {
const query = `
{
allUsers {
id
name
email
}
}
`;
return request(endpoint, query);
};
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
}
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}