A lightweight data-fetching library for React inspired by TanStack Query
Performant • Lightweight • Easy to use • Fully typed
light-query is a modern data-fetching library that provides powerful async state management for React applications. Built with TypeScript, it offers intelligent caching, concurrent request prevention, and automatic background updates while maintaining a simple and intuitive API.
Note: This library is designed for exploration and learning purposes. While fully functional and well-tested, it is not intended as a commercial solution.
A comprehensive data-fetching solution with advanced capabilities:
| Feature | Description |
|---|---|
| 🔄 Queries & Mutations | Declarative async state management with intuitive API |
| 🚀 Infinite Queries | Basic pagination and infinite loading support |
| 💾 Smart Caching | Intelligent cache system with automatic invalidation |
| ⚡ Performance | Concurrent request prevention and batch notification system |
| 🎯 TypeScript | Full TypeScript support with advanced type safety |
| 🧪 Testing | Basic testing utilities and mock support |
| 🔧 Configuration | Flexible, global configuration system |
| 📊 State Tracking | Reactive state monitoring and global query tracking |
| 🚫 Request Cancellation | Automatic request cancellation with AbortController |
| ⚛️ React Suspense | Native React Suspense integration |
Advanced implementation features:
- 📊 State Management - Sophisticated async state patterns and lifecycle management
- 🔄 Data Synchronization - Automatic UI-server synchronization with cache invalidation
- 🏗️ Architecture Design - Modular, extensible architecture with clear separation of concerns
- 🛠️ TypeScript - Advanced type system usage with generics and conditional types
- 🧪 Testing Patterns - Basic testing utilities for async React components
- 🔍 Performance Optimization - Memory-efficient rendering with caching and concurrent request prevention
npm i @nastmz/light-queryimport React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@nastmz/light-query";
import App from "./App";
// Create client with custom configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
retry: 3,
refetchInterval: 0,
suspense: false,
},
},
maxCacheSize: 50,
logger: {
error: (message, meta) => console.error(message, meta),
warn: (message, meta) => console.warn(message, meta),
}, // Optional: logging system
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);import React from "react";
import { useQuery } from "@nastmz/light-query";
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data, status, error, refetch } = useQuery<User>({
queryKey: ["user", userId],
queryFn: async ({ signal }) => {
const response = await fetch(`/api/users/${userId}`, { signal });
if (!response.ok) throw new Error("Failed to fetch user");
return response.json();
},
});
if (status === "loading") return <div>Loading...</div>;
if (error)
return (
<div>Error: {error instanceof Error ? error.message : String(error)}</div>
);
return (
<div>
<h1>{data?.name}</h1>
<p>{data?.email}</p>
<button onClick={() => refetch()}>Refresh</button>
</div>
);
}import React, { useState } from "react";
import { useMutation, useQueryClient } from "@nastmz/light-query";
function CreateUser() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const queryClient = useQueryClient();
const createUser = useMutation({
mutationFn: async (newUser: { name: string; email: string }) => {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newUser),
});
return response.json();
},
onSuccess: async () => {
await queryClient.invalidateQueries(["users"]);
setName("");
setEmail("");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createUser.mutate({ name, email });
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
required
/>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<button type="submit" disabled={createUser.status === "loading"}>
{createUser.status === "loading" ? "Creating..." : "Create User"}
</button>
</form>
);
}light-query implements an intelligent caching system that:
- Concurrent request prevention: Prevents multiple simultaneous requests for the same query
- Automatic invalidation: Refetches when data changes
- Automatic cleanup: Removes stale data based on
cacheTime - Sharing: Multiple components can share the same query
// Invalidate specific queries
await queryClient.invalidateQueries(["users"]);
// Invalidate queries starting with 'users'
await queryClient.invalidateQueries(["users"]);
// Update cache directly
queryClient.setQueryData(["user", 1], newUserData);const { status, data, error, updatedAt } = useQuery({
queryKey: ["data"],
queryFn: fetchData,
});
// status can be: 'idle', 'loading', 'error', 'success'
// You can derive loading states from status:
// const isLoading = status === 'loading'
// const isSuccess = status === 'success'
// const isError = status === 'error'useQuery({
queryKey: ["posts", { page: 1 }],
queryFn: fetchPosts,
// Cache configuration
staleTime: 5 * 60 * 1000, // Data is "fresh" for 5 minutes
cacheTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
// Behavior
retry: 3, // Retry on error
retryDelay: 1000, // Delay between retries
// Automatic refetch
refetchInterval: 30 * 1000, // Every 30 seconds
// Suspense
suspense: false, // Enable React Suspense
});Main hook for data fetching with automatic cache and state management.
Parameters:
queryKey- Unique key to identify the queryqueryFn- Function that returns a Promise with the datastaleTime- Time in ms before data is considered stalecacheTime- Time in ms data stays in cacheretry- Number of retries on errorretryDelay- Delay between retries in msrefetchInterval- Automatic refetch interval in mssuspense- Enable React Suspense support
Returns:
data- The query dataerror- Error if the query failedstatus- Current state (idle,loading,success,error)updatedAt- Timestamp of last successful fetch or errorrefetch- Function for manual refetch
Hook for mutations (POST, PUT, DELETE, etc.) with success and error callbacks.
const mutation = useMutation({
mutationFn: (variables) => createUser(variables),
onSuccess: (data) => {
// Success logic
},
onError: (error) => {
// Error handling
},
});
// Use the mutation
mutation.mutate(userData);Hook for paginated queries with infinite loading.
const result = useInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
});Hook to access the QueryClient and its methods.
const queryClient = useQueryClient();
// Invalidate queries
await queryClient.invalidateQueries(["users"]);
// Update cache
queryClient.setQueryData(["user", 1], newUserData);Hook that returns the number of queries currently fetching.
const isFetching = useIsFetching();
// Returns: numberHook that returns the number of mutations currently executing.
const mutatingCount = useIsMutating();
// Returns: numberMain class for managing global query and mutation state.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
cacheTime: 10 * 60 * 1000,
retry: 3,
retryDelay: 1000,
refetchInterval: 0,
suspense: false,
},
},
maxCacheSize: 50,
logger: {
error: (message, meta) => console.error(message, meta),
warn: (message, meta) => console.warn(message, meta),
},
});invalidateQueries(queryKey)- Invalidate specific queries (async)cancelQueries(queryKey)- Cancel running queriesgetQueryData(queryKey)- Get data from cachesetQueryData(queryKey, data)- Update cache datagetQueries(queryKey)- Get queries matching a patternclear()- Clear all cachegetActiveMutationCount()- Get number of active mutations
interface QueryOptions<T> {
queryKey: QueryKey;
queryFn: (context?: { signal?: AbortSignal }) => Promise<T>;
staleTime?: number;
cacheTime?: number;
retry?: number;
retryDelay?: number;
refetchInterval?: number;
suspense?: boolean;
}
interface MutationOptions<TData, TVariables> {
mutationFn: (variables: TVariables) => Promise<TData>;
onSuccess?: (data: TData) => void;
onError?: (error: Error) => void;
}
interface QueryResult<T> {
data: T | undefined;
error: unknown;
status: "idle" | "loading" | "success" | "error";
updatedAt: number;
refetch: () => Promise<void>;
}function UserProfile({ userId }: { userId: number }) {
// Main query to get user
const userQuery = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
// Dependent query that only runs if user exists
const postsQuery = useQuery({
queryKey: ["posts", userId],
queryFn: () => fetchUserPosts(userId),
// Note: This example shows conditional logic, but 'enabled' is not implemented
// You would need to handle this in your component logic
});
return (
<div>
{userQuery.data && (
<div>
<h1>{userQuery.data.name}</h1>
{postsQuery.data && (
<ul>
{postsQuery.data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)}
</div>
)}
</div>
);
}function UpdateTodo({ todo }: { todo: Todo }) {
const queryClient = useQueryClient();
```tsx
function UpdateTodo({ todo }: { todo: Todo }) {
const queryClient = useQueryClient();
const updateTodo = useMutation({
mutationFn: (updatedTodo: Partial<Todo>) =>
updateTodoApi(todo.id, updatedTodo),
// Note: onMutate and onSettled are not implemented in this library
// This is a conceptual example showing how optimistic updates would work
onSuccess: async (data) => {
// Refetch to sync with server
await queryClient.invalidateQueries(["todos"]);
},
onError: (error) => {
// Handle error
console.error("Failed to update todo:", error);
},
});
const handleToggle = () => {
// For optimistic updates, you would handle them manually:
// 1. Update local state optimistically
// 2. Perform mutation
// 3. Revert on error or sync on success
updateTodo.mutate({ completed: !todo.completed });
};
return (
<div>
<input type="checkbox" checked={todo.completed} onChange={handleToggle} />
<span
style={{
textDecoration: todo.completed ? "line-through" : "none",
}}
>
{todo.title}
</span>
</div>
);
}function Dashboard() {
// Multiple queries running in parallel
const userQuery = useQuery({
queryKey: ["user"],
queryFn: fetchCurrentUser,
});
const statsQuery = useQuery({
queryKey: ["stats"],
queryFn: fetchStats,
});
const notificationsQuery = useQuery({
queryKey: ["notifications"],
queryFn: fetchNotifications,
});
// Use useIsFetching to show global state
const isFetching = useIsFetching();
return (
<div>
{isFetching > 0 && (
<div>🔄 Loading data... ({isFetching} active queries)</div>
)}
<UserSection
user={userQuery.data}
loading={userQuery.status === "loading"}
/>
<StatsSection
stats={statsQuery.data}
loading={statsQuery.status === "loading"}
/>
<NotificationSection
notifications={notificationsQuery.data}
loading={notificationsQuery.status === "loading"}
/>
</div>
);
}function SearchResults() {
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
// Debounce search term
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 300);
return () => clearTimeout(timer);
}, [searchTerm]);
const searchQuery = useQuery({
queryKey: ["search", debouncedSearchTerm],
queryFn: () => searchApi(debouncedSearchTerm),
// Note: You would need to handle conditional fetching in your component
staleTime: 2 * 60 * 1000, // Cache for 2 minutes
});
// Only render results if we have a search term
if (debouncedSearchTerm.length <= 2) {
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
<div>Type at least 3 characters to search...</div>
</div>
);
}
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
{searchQuery.status === "loading" && <div>Searching...</div>}
{searchQuery.error && (
<div>
Error:{" "}
{searchQuery.error instanceof Error
? searchQuery.error.message
: String(searchQuery.error)}
</div>
)}
{searchQuery.data && (
<div>
{searchQuery.data.map((result) => (
<div key={result.id}>{result.title}</div>
))}
</div>
)}
</div>
);
}light-query includes automatic request cancellation support using AbortController:
const { data, refetch } = useQuery({
queryKey: ["data"],
queryFn: async ({ signal }) => {
const response = await fetch("/api/data", {
signal, // AbortController signal
});
return response.json();
},
});
// Queries are automatically cancelled when:
// - Component unmounts
// - queryKey changes
// - New query with same key is executedfunction TodosWithSuspense() {
const { data } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
suspense: true, // Enable Suspense
});
// No need to handle loading state
// Suspense handles it automatically
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
// In your App
function App() {
return (
<Suspense fallback={<div>Loading todos...</div>}>
<TodosWithSuspense />
</Suspense>
);
}import { Logger, LogLevel } from "@nastmz/light-query";
// Create a logger instance
const logger = new Logger(LogLevel.Info);
const queryClient = new QueryClient({
logger: {
error: (message, meta) => logger.error(message, meta),
warn: (message, meta) => logger.warn(message, meta),
},
});light-query includes comprehensive testing utilities:
import {
createMockQueryClient,
waitForQuery,
} from "@nastmz/light-query/test-utils";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@nastmz/light-query";
describe("TodoList", () => {
it("should render todos after loading", async () => {
const mockClient = createMockQueryClient();
render(
<QueryClientProvider client={mockClient}>
<TodoList />
</QueryClientProvider>
);
// Wait for query to complete
await waitForQuery(mockClient, ["todos"]);
expect(screen.getByText("Todo 1")).toBeInTheDocument();
expect(screen.getByText("Todo 2")).toBeInTheDocument();
});
it("should handle error state", async () => {
const mockClient = createMockQueryClient({
defaultOptions: {
queries: {
retry: false, // Disable retry for tests
},
},
});
// Simulate query error
mockClient.setQueryData(["todos"], () => {
throw new Error("Network error");
});
render(
<QueryClientProvider client={mockClient}>
<TodoList />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});light-query is optimized for performance:
- Concurrent Request Prevention: Prevents multiple simultaneous requests for the same query
- Batch Notifications: Multiple updates are batched to prevent unnecessary re-renders
- Automatic Cancellation: Stale requests are cancelled automatically
- Smart Cleanup: Cache is cleaned automatically based on
cacheTime - Lazy Loading: Queries only execute when needed
-
Use descriptive queryKeys:
// ❌ Bad useQuery({ queryKey: ["data"], queryFn: fetchData }); // ✅ Good useQuery({ queryKey: ["posts", "user", userId], queryFn: fetchUserPosts });
-
Configure staleTime appropriately:
// For data that changes infrequently useQuery({ queryKey: ["settings"], queryFn: fetchSettings, staleTime: 10 * 60 * 1000, // 10 minutes }); // For data that changes frequently useQuery({ queryKey: ["notifications"], queryFn: fetchNotifications, staleTime: 30 * 1000, // 30 seconds });
# Clone the repository
git clone https://github.com/NastMz/light-query.git
cd light-query
# Install dependencies
npm install
# Run tests
npm test
# Run tests in watch mode
npm test -- --watch
# Build for production
npm run build
# Lint code
npm run lint
# Format code
npm run formatnpm test- Run all tests with vitestnpm run test:watch- Run tests in watch modenpm run test:coverage- Run tests with coveragenpm run build- Production build with rollupnpm run lint- Lint with ts-standardnpm run format- Format code with ts-standard
light-query/
├── src/
│ ├── hooks/ # Main hooks
│ ├── core/ # Core logic
│ ├── react/ # React components
│ ├── types/ # TypeScript types
│ ├── utils/ # Utilities
│ ├── mocks/ # MSW mocks for testing
│ ├── test-utils/ # Testing utilities
│ └── index.ts # Main exports
├── __tests__/ # Tests
├── examples/ # Usage examples
├── package.json
├── tsconfig.json
├── rollup.config.mjs
└── vitest.config.ts
This library demonstrates comprehensive implementation of:
- React Hooks: Advanced custom hooks, useEffect patterns, useRef, useCallback
- State Management: Complex state patterns, state machines, and reactive systems
- Async Operations: Promise handling, error boundaries, and automatic cancellation
- TypeScript: Generics, conditional types, utility types, and advanced type safety
- Testing: React Testing Library, async testing patterns, and comprehensive mocking
- Observer Pattern: Reactive subscription system with efficient change detection
- Cache Management: Simple cache with TTL, intelligent invalidation strategies
- Performance Optimization: Concurrent request prevention, batching, and automatic cleanup
- Error Handling: Custom error types with recovery strategies and user-friendly messages
- Configuration Systems: Flexible, typed configuration with sensible defaults
- Modular Design: Clean separation of concerns with clear interfaces
- API Design: Intuitive, consistent API with TypeScript-first approach
- Plugin Architecture: Extensible system with configurable components
- Documentation: Comprehensive API documentation with practical examples
This project is licensed under the MIT License - see the LICENSE file for details.
Kevin Martinez - @NastMz
⭐ If you find this project useful, consider giving it a star on GitHub! ⭐