npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mateosuarezdev/query

v1.0.15

Published

Observable-based query and cache management with built-in incremental sync and offline-first persistence. React hooks included

Readme

Query

A next-generation query and cache management system for React and React Native applications. Built on Legend State observables with a pure event-driven architecture, incremental sync, and production-grade offline capabilities.

Why Query?

  • 🚀 Observable-based reactivity - Eliminates unnecessary re-renders with fine-grained updates
  • 🔄 Incremental sync built-in - Delta-based updates with declarative merge patterns
  • 📴 True offline-first - Pluggable persistence (IndexedDB on web, expo-sqlite / MMKV on React Native)
  • 🎯 Zero memory leaks - Pure event-driven architecture with automatic cleanup
  • 💎 Full type inference - End-to-end TypeScript safety
  • Battle-tested patterns - Exponential backoff, smart caching, network-aware behavior
  • 📱 Cross-platform - Works on web and React Native via custom store/network adapters

How Query Compares

| Feature | Query | Traditional Libraries | | ----------------------- | ---------------------------------- | -------------------------------- | | Reactivity | Observable-based (fine-grained) | State-based (component-level) | | Incremental Sync | Built-in with declarative API | Manual implementation required | | Offline Persistence | Pluggable (IndexedDB / expo-sqlite / MMKV) | Plugin ecosystem or manual setup | | Memory Safety | Event-driven with auto-cleanup | Varies by implementation | | Type Inference | Full (TData, TDelta) | Varies by library | | Real-Time Support | Direct observable updates | Polling or manual integration | | Learning Curve | Familiar if using observables | Depends on library patterns |

Installation

Web:

npm install @mateosuarezdev/query @legendapp/state@beta @mateosuarezdev/event-emitter idb-keyval

React Native (Expo):

npm install @mateosuarezdev/query @legendapp/state@beta @mateosuarezdev/event-emitter
# idb-keyval is not needed — pass a custom store adapter instead (see React Native section below)

Why peer dependencies?

Query externalizes these dependencies to give you maximum flexibility:

  • @legendapp/state - Use Legend State throughout your app for global state management, sharing the same instance
  • @mateosuarezdev/event-emitter - Reuse the same event system across your application
  • idb-keyval - Optional. Used as the default persistence store on web. Not required when providing a custom store adapter (e.g. React Native)

This approach keeps your bundle size minimal and gives you full control over shared dependencies.

Quick Start

Create queryService isntance and useQuery hook

import { createUseQuery, QueryService } from "@mateosuarezdev/query";

// somewhere like src/query
export const queryService = new QueryService({
  appName: "claire",
  debug: false,
});

export const useQuery = createUseQuery(queryService);

Standard usage

function PostsList() {
  const { data, status, invalidate } = useQuery({
    key: ["posts"],
    get: async () => {
      const res = await fetch("/api/posts");
      return res.json();
    },
    offline: true,
    invalidateAfterMinutes: 5,
  });

  if (status === "loading") return <div>Loading...</div>;
  if (status === "error") return <div>Error loading posts</div>;

  return (
    <div>
      {data.map((post) => (
        <Post key={post.id} {...post} />
      ))}
      <button onClick={invalidate}>Refresh</button>
    </div>
  );
}

React Native

Query works on React Native via two optional constructor parameters: store (replaces IndexedDB) and network (replaces navigator/window).

How persistence works

The service serializes the entire cache — all query data, invalidated keys, and version metadata — into a single JSON blob stored under one key (${appName}-cache-next). On startup it reads that blob once to hydrate the in-memory Legend State observable; from that point all reads and writes go through memory. Storage is only written back via a debounced flush.

expo-sqlite is the right backend because the cache blob can grow large — AsyncStorage and similar stores hit size and performance limits with large payloads. The cache table schema (id, key, value) stores one row in practice today, but is structured to support per-key storage later if ever needed.

expo-sqlite adapter

import * as SQLite from "expo-sqlite";
import NetInfo from "@react-native-community/netinfo";
import {
  PersistenceStore,
  NetworkAdapter,
  QueryService,
  createUseQuery,
} from "@mateosuarezdev/query";

// --- persistence ---
const db = SQLite.openDatabaseSync("my-app-cache.db");
db.execSync(`
  CREATE TABLE IF NOT EXISTS cache (
    id    INTEGER PRIMARY KEY AUTOINCREMENT,
    key   TEXT UNIQUE NOT NULL,
    value TEXT NOT NULL
  )
`);

const sqliteStore: PersistenceStore = {
  get: async (key) => {
    const row = db.getFirstSync<{ value: string }>(
      "SELECT value FROM cache WHERE key = ?",
      [key],
    );
    return row ? JSON.parse(row.value) : undefined;
  },
  set: async (key, value) => {
    db.runSync(
      "INSERT OR REPLACE INTO cache (key, value) VALUES (?, ?)",
      [key, JSON.stringify(value)],
    );
  },
  del: async (key) => {
    db.runSync("DELETE FROM cache WHERE key = ?", [key]);
  },
};

// --- network ---
let isConnected = true;
NetInfo.fetch().then((s) => { isConnected = s.isConnected ?? true; });

const netAdapter: NetworkAdapter = {
  isOnline: () => isConnected,
  onOnline: (cb) =>
    NetInfo.addEventListener((state) => {
      const online = state.isConnected ?? true;
      if (online && !isConnected) cb();
      isConnected = online;
    }),
};

// --- service ---
export const queryService = new QueryService({
  appName: "my-app",
  store: sqliteStore,
  network: netAdapter,
});

export const useQuery = createUseQuery(queryService);

Usage in components is identical to the web version — the adapter difference is entirely at setup time.

Incremental Sync - The Game Changer

Instead of refetching all data, sync only what changed:

import { defaultMergeArray } from "@mateosuarezdev/query";

type Post = { id: string; title: string; content: string };
type PostDelta = { modified: Post[]; deleted: string[] };

const { data, sync, invalidate } = useQuery({
  key: ["posts"],

  // Full data fetch (initial load)
  get: async () => {
    const res = await fetch("/api/posts");
    return res.json() as Post[];
  },

  // Delta fetch (subsequent updates)
  sync: async (lastSync) => {
    const res = await fetch(`/api/posts?since=${lastSync}`);
    return res.json() as PostDelta;
  },

  // Built-in merge for arrays with { id } - handles insert, update, delete
  merge: defaultMergeArray,

  // Optional: notification after sync completes
  onSync: (mergedData) => {
    console.log("Sync complete!", mergedData.length, "posts");
  },

  invalidateAfterMinutes: 5, // Auto-sync every 5 minutes
});

// Manual operations
invalidate(); // Force full refetch
sync(); // Force delta sync

Benefits:

  • 90% less bandwidth - Only fetch what changed
  • Faster updates - Smaller payloads, quicker response
  • Better UX - Instant feedback, no full reloads
  • Type-safe - Full inference from sync to merge
  • Zero boilerplate - Use defaultMergeArray for common cases

Note: For complex data structures, you can define custom merge logic. See Custom Merge Logic for examples.

Core Features

Smart Caching with Observable State

Built on Legend State for ultra-efficient reactivity:

const { data, update } = useQuery({
  key: ["settings"],
  get: () => fetchSettings(),
});

// Direct observable mutations (no re-render until accessed)
update((settings) => {
  settings.theme.set("dark"); // Only components using theme re-render
  settings.language.set("en"); // Only components using language re-render
});

Offline-First with Debounced Persistence

Automatic IndexedDB persistence with smart batching:

useQuery({
  key: ["drafts"],
  get: () => fetchDrafts(),
  offline: true, // Persists to IndexedDB
});

// Multiple updates within 500ms = single IndexedDB write
update1(); // Schedule persist
update2(); // Cancel previous, schedule new
update3(); // Cancel previous, schedule new
// ... 500ms later: single persist operation

Pattern Matching Invalidation

Invalidate multiple related queries at once:

// Invalidate all user queries
await queryService.invalidateQueries(["user"]);

// Invalidate specific user
await queryService.invalidateQueries(["user", "123"]);

// Invalidate all posts by status
await queryService.invalidateQueries(["posts", { status: "draft" }]);

// Same with sync
await queryService.syncQueries(["posts"]); // Sync all posts

Event-Driven Callbacks (Memory Safe)

All callbacks flow through event system - no memory leaks:

useQuery({
  key: ["notifications"],
  get: () => fetchNotifications(),
  onLoad: (data) => {
    // Called when data loads
    // Automatically cleaned up on unmount ✅
    console.log("Loaded", data.length, "notifications");
  },
  onInvalidate: (data) => {
    // Called after refetch
    console.log("Refreshed notifications");
  },
  onSync: (data) => {
    // Called after delta sync
    console.log("Synced notifications");
  },
});

Automatic Retries & One-Time Operations

All queries have built-in retry logic with exponential backoff. For one-time operations outside of queries, use the queryInPlace and mutateInPlace helpers:

import { queryInPlace, mutateInPlace } from "@mateosuarezdev/query";

// One-time fetch with retries
const { error, result } = await queryInPlace({
  getFn: async () => {
    const res = await fetch("/api/analytics");
    return res.json();
  },
  retries: 3,
});

// One-time mutation
const { error } = await mutateInPlace({
  fn: async () => {
    await fetch("/api/posts", { method: "POST", body: JSON.stringify(data) });
  },
  onSuccess: () => {
    queryService.invalidateQueries(["posts"]);
  },
});

These helpers are also used internally by the query system itself, ensuring consistent error handling and retry behavior throughout your app.

Network-Aware Behavior

Handles offline/online transitions automatically:

useQuery({
  key: ["feed"],
  sync: (lastSync) => fetchFeedDelta(lastSync),
  merge: defaultMergeArray,
});

// When offline:
// - Returns cached data
// - Queues sync/invalidation requests

// When back online:
// - Automatically processes queued requests
// - Syncs all stale queries

API Reference

useQuery<TData, TDelta>(options)

Options:

{
  key: QueryKey;                          // Query identifier
  get?: (lastSync?: string) => Promise<TData>;     // Fetch full data
  sync?: (lastSync: string) => Promise<TDelta>;    // Fetch delta
  merge?: (delta: TDelta, update) => void;         // Merge delta
  offline?: boolean;                      // Enable offline (default: true)
  enabled?: boolean;                      // Enable/disable (default: true)
  invalidateAfterMinutes?: number;        // Auto-invalidate (default: 5)
  onLoad?: (data: TData) => void;         // Called on load
  onInvalidate?: (data: TData) => void;   // Called on invalidate
  onSync?: (data: TData) => void;         // Called on sync
}

Returns:

{
  data: TData | null;                     // Query data
  status: 'loading' | 'success' | 'error' | 'invalidating' | 'syncing' | 'stale';
  lastUpdate: number;                     // Timestamp of last update
  lastSync: string;                       // ISO string of last sync
  isInvalidated: boolean;                 // Whether query needs refresh
  invalidate: () => void;                 // Force full refetch
  sync: () => void;                       // Force delta sync (if sync provided)
  update: (updater) => void;              // Update cache directly
}

new QueryService(options)

new QueryService({
  appName: string;          // Required. Used as the storage key prefix.
  debug?: boolean;          // Log cache operations (default: false)
  testMode?: boolean;       // Clears persisted cache on init (default: false)
  store?: PersistenceStore; // Custom storage adapter. Defaults to idb-keyval on web.
  network?: NetworkAdapter; // Custom network adapter. Defaults to navigator/window on web.
})

Both PersistenceStore and NetworkAdapter are exported from the package:

interface PersistenceStore {
  get(key: string): Promise<any>;
  set(key: string, value: any): Promise<void>;
  del(key: string): Promise<void>;
}

interface NetworkAdapter {
  isOnline(): boolean;
  onOnline(callback: () => void): () => void; // returns unsubscribe
}

queryService

Global service for query management:

// Initialize cache
await queryService.initCache();

// Add query programmatically
await queryService.addQuery({
  key: ["data"],
  get: () => fetchData(),
  offline: true,
});

// Invalidate queries
await queryService.invalidateQueries(["data"]);
await queryService.invalidateQueries([]); // Invalidate all

// Sync queries
await queryService.syncQueries(["posts"]);

// Update query data
queryService.updateQuery(["data"], (dataObs) => {
  dataObs.value.set(newValue);
});

Common Patterns

List + Detail

// List query
const { data: posts } = useQuery({
  key: ["posts"],
  get: () => fetchPosts(),
  sync: (lastSync) => fetchPostsDelta(lastSync),
  merge: defaultMergeArray,
});

// Detail query
const { data: post } = useQuery({
  key: ["post", postId],
  get: () => fetchPost(postId),
});

// After updating a post
async function updatePost(data) {
  await api.updatePost(postId, data);
  await queryService.invalidateQueries(["posts"]); // Refresh list
  await queryService.invalidateQueries(["post", postId]); // Refresh detail
}

Pagination

function PostsList() {
  const [page, setPage] = useState(1);

  const { data, status } = useQuery({
    key: ["posts", { page }],
    get: () => fetchPosts(page),
  });

  return (
    <>
      {data?.posts.map((post) => (
        <Post key={post.id} {...post} />
      ))}
      <button onClick={() => setPage((p) => p + 1)}>Next Page</button>
    </>
  );
}

Dependent Queries

function UserPosts({ userId }) {
  // First get user
  const { data: user } = useQuery({
    key: ["user", userId],
    get: () => fetchUser(userId),
  });

  // Then get their posts (waits for user)
  const { data: posts } = useQuery({
    key: ["posts", { authorId: userId }],
    get: () => fetchUserPosts(userId),
    enabled: !!user, // Only run when user loaded
  });

  if (!user) return <div>Loading user...</div>;
  if (!posts) return <div>Loading posts...</div>;

  return <PostsList posts={posts} author={user} />;
}

Optimistic Updates

const { data, update, invalidate } = useQuery({
  key: ["todos"],
  get: () => fetchTodos(),
});

async function toggleTodo(todoId) {
  // Optimistically update UI
  update((todosObs) => {
    const todo = todosObs.find((t) => t.id.peek() === todoId);
    if (todo) {
      todo.completed.set(!todo.completed.peek());
    }
  });

  // Send request
  await api.toggleTodo(todoId);

  // Refresh from server to confirm
  invalidate();
}

Prefetching

function PostLink({ postId }) {
  const prefetch = () => {
    queryService.addQuery({
      key: ["post", postId],
      get: () => fetchPost(postId),
    });
  };

  return (
    <a href={`/posts/${postId}`} onMouseEnter={prefetch}>
      View Post
    </a>
  );
}

Real-Time Updates

For real-time features (WebSockets, Server-Sent Events), directly update the observable cache - it's more powerful than sync and still automatically persists:

type Message = { id: string; text: string; timestamp: string };

const { data: messages, update } = useQuery({
  key: ["chat", roomId],
  get: () => fetchMessages(roomId),
  offline: true,
});

// Connect to WebSocket
useEffect(() => {
  const ws = new WebSocket(`wss://api.example.com/chat/${roomId}`);

  ws.onmessage = (event) => {
    const message = JSON.parse(event.data) as Message;

    // Directly update the observable - instant UI update + auto-persists!
    update((messagesObs) => {
      messagesObs.push(message);
    });

    playNotification();
  };

  return () => ws.close();
}, [roomId]);

Why this is better than sync:

  • Instant updates - No polling, no delay
  • Still auto-persists - Offline support included
  • More efficient - Push-based, not pull-based
  • Full control - Handle any real-time protocol (WebSockets, SSE, Firebase, etc.)

Note: The sync method is designed for periodic background synchronization, not real-time updates.

Advanced Usage

Custom Merge Logic

For complex data structures:

type AppState = {
  users: Record<string, User>;
  settings: Settings;
  metadata: Metadata;
};

type StateDelta = {
  usersModified: User[];
  usersDeleted: string[];
  settings?: Settings;
  metadata?: Metadata;
};

useQuery({
  key: ["app-state"],
  sync: (lastSync) => fetchStateDelta(lastSync),
  merge: (delta, update) => {
    update((stateObs) => {
      // Update users object
      delta.usersModified.forEach((user) => {
        stateObs.users[user.id].set(user);
      });
      delta.usersDeleted.forEach((id) => {
        delete stateObs.users[id];
      });

      // Update settings if changed
      if (delta.settings) {
        stateObs.settings.set(delta.settings);
      }

      // Update metadata if changed
      if (delta.metadata) {
        stateObs.metadata.set(delta.metadata);
      }
    });
  },
});

Conditional Queries

function Profile({ userId }) {
  const { data: user } = useQuery({
    key: ["user", userId],
    get: () => fetchUser(userId),
    enabled: !!userId, // Only run if userId exists
  });

  const { data: premium } = useQuery({
    key: ["premium", userId],
    get: () => fetchPremiumStatus(userId),
    enabled: !!user && user.isPremium, // Only for premium users
  });
}

Server Response Transformation

When your server returns data in a different format:

// Server returns: { data: Post[], deletedIds: string[], timestamp: string }
// We need: { modified: Post[], deleted: string[] }

useQuery({
  key: ["posts"],
  sync: async (lastSync) => {
    const res = await fetch(`/api/posts?since=${lastSync}`);
    const { data, deletedIds } = await res.json();

    // Transform to expected format
    return {
      modified: data,
      deleted: deletedIds,
    };
  },
  merge: defaultMergeArray,
});

Performance Optimization

Debounced Persistence

Automatic batching reduces IndexedDB writes:

// Multiple updates in quick succession
update1(); // t=0ms   - schedule persist at t=500ms
update2(); // t=100ms - cancel previous, schedule at t=600ms
update3(); // t=200ms - cancel previous, schedule at t=700ms
update4(); // t=300ms - cancel previous, schedule at t=800ms
// t=800ms - Single persist operation (4 updates → 1 write)

Selective Offline Persistence

Only persist what you need offline:

// Critical data - persist
useQuery({
  key: ["user-data"],
  get: () => fetchUserData(),
  offline: true, // Saved to IndexedDB
});

// Transient data - don't persist
useQuery({
  key: ["search-results"],
  get: () => searchPosts(query),
  offline: false, // Memory only
});

Pattern-Based Invalidation

Efficiently refresh related queries:

// Instead of invalidating each query individually:
await queryService.invalidateQueries(["user", "1"]);
await queryService.invalidateQueries(["user", "2"]);
await queryService.invalidateQueries(["user", "3"]);

// Invalidate all at once:
await queryService.invalidateQueries(["user"]); // Matches all user queries

TypeScript Support

Full type safety with automatic inference:

type User = { id: string; name: string; email: string };
type UserDelta = { modified: User[]; deleted: string[] };

const { data, sync } = useQuery({
  key: ["users"],
  get: async () => {
    // Return type infers TData = User[]
    const res = await fetch("/api/users");
    return res.json() as User[];
  },
  sync: async (lastSync) => {
    // Return type infers TDelta = UserDelta
    const res = await fetch(`/api/users?since=${lastSync}`);
    return res.json() as UserDelta;
  },
  merge: (delta, update) => {
    // delta is typed as UserDelta ✅
    // update receives Observable<User[]> ✅
    update((usersObs) => {
      // usersObs is Observable<User[]> ✅
      delta.modified.forEach((user) => {
        // user is typed as User ✅
      });
    });
  },
  onSync: (data) => {
    // data is typed as User[] ✅
  },
});

// data is User[] | null ✅
// sync is () => void (only if sync function provided) ✅

Architecture

Event-Driven Design

All callbacks use events, preventing memory leaks:

Component mounts → registers event listeners
Query updates → emits events
Event listeners → handle callbacks
Component unmounts → disposes listeners ✅ No leaks!

Cache Structure

{
  queries: {
    '["user","123"]': {
      data: { id: '123', name: 'John' },
      status: 'success',
      lastUpdate: 1234567890,
      lastSync: '2024-01-01T00:00:00Z',
      isInvalidated: false,
      offline: true
    }
  },
  invalidatedKeys: ['["posts"]'],
  metadata: {
    version: '2.0.1',
    lastUpdate: 1234567890
  }
}

Persistence Strategy

  • Only offline: true queries are persisted
  • The entire cache is serialized as a single JSON blob under one key (${appName}-cache-next) — simple to implement on any storage backend
  • Debounced writes (500ms) reduce I/O
  • Old queries (>7 days) automatically cleaned
  • Version mismatch triggers cache reset
  • Graceful fallback to memory-only mode if storage is unavailable

Migration Guide

Migrating from other query libraries is straightforward. Here are common patterns:

Basic Query

Before:

// Using queryKey and queryFn
const { data, refetch } = useQuery({
  queryKey: ["todos"],
  queryFn: fetchTodos,
});

After:

// Using key and get
const { data, invalidate } = useQuery({
  key: ["todos"],
  get: fetchTodos,
});

Mutations

Before:

// Separate mutation hook
const mutation = useMutation({
  mutationFn: updateTodo,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ["todos"] });
  },
});

After:

// Direct mutation with helper
import { mutateInPlace } from "@mateosuarezdev/query";

const { error } = await mutateInPlace({
  fn: () => updateTodo(data),
  onSuccess: () => {
    queryService.invalidateQueries(["todos"]);
  },
});

Optimistic Updates

Before:

// Complex context management
const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ["todos"] });
    const previous = queryClient.getQueryData(["todos"]);
    queryClient.setQueryData(["todos"], (old) => [...old, newTodo]);
    return { previous };
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(["todos"], context.previous);
  },
});

After:

// Direct observable updates
const { update, invalidate } = useQuery({
  key: ["todos"],
  get: fetchTodos,
});

async function addTodo(newTodo) {
  // Optimistic update
  update((todosObs) => todosObs.push(newTodo));

  // Mutate
  const { error } = await mutateInPlace({
    fn: () => createTodo(newTodo),
  });

  // Refresh to confirm
  if (!error) invalidate();
}

Best Practices

  1. Use incremental sync for large datasets - Saves bandwidth and improves performance
  2. Define merge logic once - Store in shared utils, reuse across queries
  3. Enable offline selectively - Only persist data that makes sense offline
  4. Use pattern matching for related queries - Invalidate/sync groups efficiently
  5. Leverage observables - Direct mutations for fine-grained reactivity
  6. Set appropriate invalidation times - Balance freshness vs. network usage
  7. Handle loading states - Always check status before using data
  8. Use type inference - Let TypeScript infer types from your functions

Troubleshooting

Query not updating

  • Check if query key changed (creates new query)
  • Verify enabled is true
  • Check network connectivity
  • Look for invalidation conflicts

Sync not working

  • Ensure sync function returns proper delta format: { modified: T[], deleted: string[] }
  • Check merge function is defined (use built in helpers where possible)
  • Verify lastSync timestamp is being sent to server

Offline data not persisting

  • Ensure offline: true is set (default)
  • On web: check IndexedDB browser support and verify not in private/incognito mode
  • On React Native: ensure a store adapter was passed to the constructor
  • Check queryService.isPersistenceEnabled — if false, check queryService.getPersistenceError

Performance issues

  • Use more specific invalidation patterns
  • Increase invalidateAfterMinutes for stable data
  • Consider disabling offline for large datasets
  • Check for too many simultaneous queries

Contributing

This is part of my releasing tech series. Issues are welcome!

Credits

License

README LICENSE.