Skip to content

A minimal, from-scratch React data-fetch library with hooks for queries, mutations, and infinite loading—configurable caching, retries, and Suspense support.

License

Notifications You must be signed in to change notification settings

NastMz/light-query

Repository files navigation

🚀 light-query

A lightweight data-fetching library for React inspired by TanStack Query

React 18+ TypeScript 5.0+ MIT License

Performant • Lightweight • Easy to use • Fully typed


🎯 Overview

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.

✨ Key Features

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

🎨 Technical Highlights

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

📦 Installation & Setup

npm i @nastmz/light-query

🚀 Quick Start

Step 1: Setup the Provider

import 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>
);

Step 2: Your First Query

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>
  );
}

Step 3: Your First Mutation

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>
  );
}

🔍 Core Concepts

Caching and Invalidation

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);

Query States

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'

Query Options

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
});

📖 API Reference

Hooks

useQuery<T>(options: QueryOptions<T>)

Main hook for data fetching with automatic cache and state management.

Parameters:

  • queryKey - Unique key to identify the query
  • queryFn - Function that returns a Promise with the data
  • staleTime - Time in ms before data is considered stale
  • cacheTime - Time in ms data stays in cache
  • retry - Number of retries on error
  • retryDelay - Delay between retries in ms
  • refetchInterval - Automatic refetch interval in ms
  • suspense - Enable React Suspense support

Returns:

  • data - The query data
  • error - Error if the query failed
  • status - Current state (idle, loading, success, error)
  • updatedAt - Timestamp of last successful fetch or error
  • refetch - Function for manual refetch

useMutation<TData, TVariables>(options: MutationOptions<TData, TVariables>)

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);

useInfiniteQuery<T>(options: InfiniteQueryOptions<T>)

Hook for paginated queries with infinite loading.

const result = useInfiniteQuery({
  queryKey: ["posts"],
  queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
  getNextPageParam: (lastPage, pages) => lastPage.nextPage,
});

useQueryClient()

Hook to access the QueryClient and its methods.

const queryClient = useQueryClient();

// Invalidate queries
await queryClient.invalidateQueries(["users"]);

// Update cache
queryClient.setQueryData(["user", 1], newUserData);

useIsFetching()

Hook that returns the number of queries currently fetching.

const isFetching = useIsFetching();
// Returns: number

useIsMutating()

Hook that returns the number of mutations currently executing.

const mutatingCount = useIsMutating();
// Returns: number

QueryClient

Main 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),
  },
});

Main Methods

  • invalidateQueries(queryKey) - Invalidate specific queries (async)
  • cancelQueries(queryKey) - Cancel running queries
  • getQueryData(queryKey) - Get data from cache
  • setQueryData(queryKey, data) - Update cache data
  • getQueries(queryKey) - Get queries matching a pattern
  • clear() - Clear all cache
  • getActiveMutationCount() - Get number of active mutations

TypeScript Types

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>;
}

🎯 Advanced Patterns

Query Dependencies

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>
  );
}

Mutation Handling

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>
  );
}

Parallel Queries

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>
  );
}

Search with Debounce

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>
  );
}

🛠️ Advanced Features

Request Cancellation

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 executed

React Suspense

function 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>
  );
}

Logging System

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),
  },
});

🧪 Testing

light-query includes comprehensive testing utilities:

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();
    });
  });
});

📊 Performance

light-query is optimized for performance:

Built-in Optimizations

  • 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

Best Practices

  1. Use descriptive queryKeys:

    // ❌ Bad
    useQuery({ queryKey: ["data"], queryFn: fetchData });
    
    // ✅ Good
    useQuery({ queryKey: ["posts", "user", userId], queryFn: fetchUserPosts });
  2. 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
    });

🛠️ Development

Environment Setup

# 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 format

Available Scripts

  • npm test - Run all tests with vitest
  • npm run test:watch - Run tests in watch mode
  • npm run test:coverage - Run tests with coverage
  • npm run build - Production build with rollup
  • npm run lint - Lint with ts-standard
  • npm run format - Format code with ts-standard

Project Structure

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

🎓 Technical Implementation

This library demonstrates comprehensive implementation of:

Core Technologies

  • 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

Advanced Implementation Details

  • 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

Architecture Patterns

  • 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

📄 License

This project is licensed under the MIT License - see the LICENSE file for details.

👨‍💻 Author

Kevin Martinez - @NastMz


⭐ If you find this project useful, consider giving it a star on GitHub! ⭐

About

A minimal, from-scratch React data-fetch library with hooks for queries, mutations, and infinite loading—configurable caching, retries, and Suspense support.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published