React Hooks
The @54vie/api package provides React hooks built on top of TanStack Query (React Query) for seamless data fetching, caching, and state management.
Installation
npm install @54vie/api @tanstack/react-query
# or
yarn add @54vie/api @tanstack/react-query
# or
pnpm add @54vie/api @tanstack/react-query
Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ApiProvider } from '@54vie/api';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
retry: 3,
refetchOnWindowFocus: false,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<ApiProvider baseURL="https://api.54vie.com/v1">
<YourApp />
</ApiProvider>
</QueryClientProvider>
);
}
TypeScript Interfaces
// Query Options
interface UseQueryOptions<TData, TError = ApiError> {
queryKey: QueryKey;
enabled?: boolean;
staleTime?: number;
gcTime?: number;
refetchInterval?: number | false;
refetchOnMount?: boolean | 'always';
refetchOnWindowFocus?: boolean | 'always';
refetchOnReconnect?: boolean | 'always';
retry?: boolean | number | ((failureCount: number, error: TError) => boolean);
retryDelay?: number | ((attemptIndex: number) => number);
select?: (data: TData) => TData;
placeholderData?: TData | (() => TData);
initialData?: TData | (() => TData);
onSuccess?: (data: TData) => void;
onError?: (error: TError) => void;
onSettled?: (data: TData | undefined, error: TError | null) => void;
}
// Query Result
interface UseQueryResult<TData, TError = ApiError> {
data: TData | undefined;
error: TError | null;
isLoading: boolean;
isFetching: boolean;
isSuccess: boolean;
isError: boolean;
isPending: boolean;
isStale: boolean;
status: 'pending' | 'error' | 'success';
fetchStatus: 'fetching' | 'paused' | 'idle';
refetch: () => Promise<UseQueryResult<TData, TError>>;
dataUpdatedAt: number;
errorUpdatedAt: number;
}
// Mutation Options
interface UseMutationOptions<TData, TVariables, TError = ApiError> {
mutationKey?: MutationKey;
onMutate?: (variables: TVariables) => Promise<unknown> | unknown;
onSuccess?: (data: TData, variables: TVariables, context: unknown) => void;
onError?: (error: TError, variables: TVariables, context: unknown) => void;
onSettled?: (
data: TData | undefined,
error: TError | null,
variables: TVariables,
context: unknown
) => void;
retry?: boolean | number;
retryDelay?: number;
}
// Mutation Result
interface UseMutationResult<TData, TVariables, TError = ApiError> {
data: TData | undefined;
error: TError | null;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
isIdle: boolean;
isPending: boolean;
status: 'idle' | 'pending' | 'error' | 'success';
mutate: (variables: TVariables) => void;
mutateAsync: (variables: TVariables) => Promise<TData>;
reset: () => void;
variables: TVariables | undefined;
}
// Infinite Query Options
interface UseInfiniteQueryOptions<TData, TError = ApiError> {
queryKey: QueryKey;
initialPageParam: unknown;
getNextPageParam: (lastPage: TData, allPages: TData[]) => unknown | undefined;
getPreviousPageParam?: (firstPage: TData, allPages: TData[]) => unknown | undefined;
maxPages?: number;
enabled?: boolean;
staleTime?: number;
gcTime?: number;
refetchInterval?: number | false;
}
// Infinite Query Result
interface UseInfiniteQueryResult<TData, TError = ApiError> {
data: InfiniteData<TData> | undefined;
error: TError | null;
isLoading: boolean;
isFetching: boolean;
isFetchingNextPage: boolean;
isFetchingPreviousPage: boolean;
isSuccess: boolean;
isError: boolean;
hasNextPage: boolean;
hasPreviousPage: boolean;
fetchNextPage: () => Promise<UseInfiniteQueryResult<TData, TError>>;
fetchPreviousPage: () => Promise<UseInfiniteQueryResult<TData, TError>>;
refetch: () => Promise<UseInfiniteQueryResult<TData, TError>>;
}
interface InfiniteData<TData> {
pages: TData[];
pageParams: unknown[];
}
// API Types
interface ApiError {
message: string;
status?: number;
code?: string;
data?: unknown;
}
type QueryKey = readonly unknown[];
type MutationKey = readonly unknown[];
useQuery Hook
The useQuery hook is used for fetching and caching data.
Basic Usage
import { useQuery } from '@54vie/api';
interface User {
id: string;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery<User>({
queryKey: ['users', userId],
url: `/users/${userId}`,
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
With Query Parameters
interface UsersResponse {
users: User[];
total: number;
page: number;
}
function UsersList({ page, status }: { page: number; status: string }) {
const { data, isLoading, isFetching } = useQuery<UsersResponse>({
queryKey: ['users', { page, status }],
url: '/users',
params: {
page,
limit: 10,
status,
},
});
return (
<div>
{isFetching && <LoadingOverlay />}
{data?.users.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}
Conditional Fetching
function UserDetails({ userId }: { userId: string | null }) {
const { data } = useQuery<User>({
queryKey: ['users', userId],
url: `/users/${userId}`,
// Only fetch when userId is available
enabled: !!userId,
});
return data ? <UserCard user={data} /> : null;
}
Dependent Queries
function UserPosts({ userId }: { userId: string }) {
// First query - get user
const userQuery = useQuery<User>({
queryKey: ['users', userId],
url: `/users/${userId}`,
});
// Second query - depends on first query
const postsQuery = useQuery<Post[]>({
queryKey: ['users', userId, 'posts'],
url: `/users/${userId}/posts`,
// Only fetch when user data is available
enabled: !!userQuery.data,
});
return (
<div>
<UserHeader user={userQuery.data} />
<PostsList posts={postsQuery.data} loading={postsQuery.isLoading} />
</div>
);
}
Data Transformation with Select
interface ApiUser {
user_id: string;
full_name: string;
email_address: string;
created_at: string;
}
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
function useUser(userId: string) {
return useQuery<ApiUser, User>({
queryKey: ['users', userId],
url: `/users/${userId}`,
// Transform API response to app format
select: (data) => ({
id: data.user_id,
name: data.full_name,
email: data.email_address,
createdAt: new Date(data.created_at),
}),
});
}
Polling with refetchInterval
function LiveStats() {
const { data } = useQuery<Stats>({
queryKey: ['stats'],
url: '/stats',
// Refetch every 5 seconds
refetchInterval: 5000,
// Stop polling when tab is hidden
refetchIntervalInBackground: false,
});
return <StatsDisplay stats={data} />;
}
Placeholder Data
function UserProfile({ userId }: { userId: string }) {
const { data } = useQuery<User>({
queryKey: ['users', userId],
url: `/users/${userId}`,
// Show placeholder while loading
placeholderData: {
id: userId,
name: 'Loading...',
email: '',
},
});
return <UserCard user={data} />;
}
useMutation Hook
The useMutation hook is used for creating, updating, and deleting data.
Basic Usage
import { useMutation, useQueryClient } from '@54vie/api';
interface CreateUserInput {
name: string;
email: string;
}
function CreateUserForm() {
const queryClient = useQueryClient();
const createUser = useMutation<User, CreateUserInput>({
url: '/users',
method: 'POST',
onSuccess: (newUser) => {
// Invalidate and refetch users list
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success(`User ${newUser.name} created!`);
},
onError: (error) => {
toast.error(error.message);
},
});
const handleSubmit = (data: CreateUserInput) => {
createUser.mutate(data);
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? 'Creating...' : 'Create User'}
</button>
</form>
);
}
Update Mutation
interface UpdateUserInput {
id: string;
name?: string;
email?: string;
}
function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation<User, UpdateUserInput>({
url: (variables) => `/users/${variables.id}`,
method: 'PATCH',
onSuccess: (updatedUser) => {
// Update the specific user in cache
queryClient.setQueryData(['users', updatedUser.id], updatedUser);
// Invalidate the users list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
function EditUserForm({ user }: { user: User }) {
const updateUser = useUpdateUser();
const handleSave = (data: Partial<User>) => {
updateUser.mutate({ id: user.id, ...data });
};
return (
<form onSubmit={handleSave}>
{/* form fields */}
{updateUser.isError && <ErrorAlert error={updateUser.error} />}
</form>
);
}
Delete Mutation
function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation<void, { id: string }>({
url: (variables) => `/users/${variables.id}`,
method: 'DELETE',
onSuccess: (_, variables) => {
// Remove from cache
queryClient.removeQueries({ queryKey: ['users', variables.id] });
// Invalidate list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
function DeleteUserButton({ userId }: { userId: string }) {
const deleteUser = useDeleteUser();
const handleDelete = () => {
if (confirm('Are you sure?')) {
deleteUser.mutate({ id: userId });
}
};
return (
<button onClick={handleDelete} disabled={deleteUser.isPending}>
{deleteUser.isPending ? 'Deleting...' : 'Delete'}
</button>
);
}
Optimistic Updates
interface Todo {
id: string;
title: string;
completed: boolean;
}
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation<Todo, { id: string; completed: boolean }>({
url: (variables) => `/todos/${variables.id}`,
method: 'PATCH',
// Optimistic update
onMutate: async (variables) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Optimistically update
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) =>
todo.id === variables.id
? { ...todo, completed: variables.completed }
: todo
)
);
// Return context with snapshot
return { previousTodos };
},
// Rollback on error
onError: (error, variables, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
toast.error('Failed to update todo');
},
// Refetch after success or error
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
Async Mutation
async function handleSubmit() {
try {
const newUser = await createUser.mutateAsync({
name: 'John',
email: 'john@example.com',
});
// Navigate after successful creation
router.push(`/users/${newUser.id}`);
} catch (error) {
// Error is already handled by onError callback
console.error('Creation failed:', error);
}
}
useInfiniteQuery Hook
The useInfiniteQuery hook is used for paginated or infinite scroll data.
Basic Usage
import { useInfiniteQuery } from '@54vie/api';
interface PostsResponse {
posts: Post[];
nextCursor: string | null;
hasMore: boolean;
}
function InfinitePostsList() {
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<PostsResponse>({
queryKey: ['posts'],
url: '/posts',
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
if (isLoading) return <Spinner />;
return (
<div>
{data?.pages.map((page, i) => (
<Fragment key={i}>
{page.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</Fragment>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'No more posts'}
</button>
</div>
);
}
Offset-Based Pagination
interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
}
function useInfiniteUsers() {
return useInfiniteQuery<PaginatedResponse<User>>({
queryKey: ['users', 'infinite'],
url: '/users',
initialPageParam: 1,
getNextPageParam: (lastPage) => {
const totalPages = Math.ceil(lastPage.total / lastPage.pageSize);
return lastPage.page < totalPages ? lastPage.page + 1 : undefined;
},
getPreviousPageParam: (firstPage) => {
return firstPage.page > 1 ? firstPage.page - 1 : undefined;
},
});
}
Infinite Scroll with Intersection Observer
import { useRef, useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
function InfiniteScrollList() {
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery<PostsResponse>({
queryKey: ['posts'],
url: '/posts',
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// Fetch next page when sentinel comes into view
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div>
{data?.pages.map((page, i) => (
<Fragment key={i}>
{page.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</Fragment>
))}
{/* Sentinel element */}
<div ref={ref}>
{isFetchingNextPage && <Spinner />}
</div>
</div>
);
}
Bidirectional Infinite Query
function ChatMessages({ channelId }: { channelId: string }) {
const {
data,
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
} = useInfiniteQuery<MessagesResponse>({
queryKey: ['channels', channelId, 'messages'],
url: `/channels/${channelId}/messages`,
initialPageParam: { cursor: null, direction: 'forward' },
getNextPageParam: (lastPage) =>
lastPage.hasMore ? { cursor: lastPage.nextCursor, direction: 'forward' } : undefined,
getPreviousPageParam: (firstPage) =>
firstPage.hasPrevious ? { cursor: firstPage.prevCursor, direction: 'backward' } : undefined,
maxPages: 10, // Keep only 10 pages in memory
});
return (
<div>
{hasPreviousPage && (
<button onClick={() => fetchPreviousPage()}>
{isFetchingPreviousPage ? 'Loading...' : 'Load Previous'}
</button>
)}
{data?.pages.map((page) =>
page.messages.map((message) => (
<Message key={message.id} message={message} />
))
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Query Client Utilities
Prefetching Data
import { useQueryClient } from '@54vie/api';
function UsersList({ users }: { users: User[] }) {
const queryClient = useQueryClient();
const handleMouseEnter = (userId: string) => {
// Prefetch user details on hover
queryClient.prefetchQuery({
queryKey: ['users', userId],
queryFn: () => client.get(`/users/${userId}`),
});
};
return (
<ul>
{users.map((user) => (
<li
key={user.id}
onMouseEnter={() => handleMouseEnter(user.id)}
>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))}
</ul>
);
}
Manual Cache Updates
const queryClient = useQueryClient();
// Set data directly
queryClient.setQueryData(['users', userId], updatedUser);
// Update with callback
queryClient.setQueryData<User[]>(['users'], (old) =>
old?.map((user) => (user.id === userId ? updatedUser : user))
);
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ['users'] });
// Remove queries
queryClient.removeQueries({ queryKey: ['users', userId] });
// Reset queries
queryClient.resetQueries({ queryKey: ['users'] });
Best Practices
- Use meaningful query keys - Include all variables that affect the query
- Configure stale time - Set appropriate
staleTimebased on data freshness needs - Implement optimistic updates - Provide instant feedback for mutations
- Handle loading and error states - Always show appropriate UI feedback
- Use
select- Transform data at the hook level instead of in components - Prefetch data - Improve perceived performance with prefetching
Related
- ApiClient - Core HTTP client
- WebSocket Client - Real-time communication
- Caching - Response caching and offline support