Skip to main content

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

  1. Use meaningful query keys - Include all variables that affect the query
  2. Configure stale time - Set appropriate staleTime based on data freshness needs
  3. Implement optimistic updates - Provide instant feedback for mutations
  4. Handle loading and error states - Always show appropriate UI feedback
  5. Use select - Transform data at the hook level instead of in components
  6. Prefetch data - Improve perceived performance with prefetching