reactive-query
v0.4.2
Published
A framework-agnostic library for model part in MVVM architectural pattern, automating querying, storing, and caching data in frontend applications based on MVVM or any MV*, CQS, and reactive programming paradigms.
Downloads
68
Readme
Reactive Query
A framework-agnostic library for model part in MVVM architectural pattern, automating querying, storing, and caching data in frontend applications based on MVVM or any MV*, CQS, and reactive programming paradigms.
Table of Contents
- Reactive Query
Description
Reactive Query is a framework-agnostic library designed specifically for the Model part in the MVVM (Model-View-ViewModel) or any MV* architectural pattern. It automates the process of querying, storing, and managing data in frontend applications by implementing CQS (Command Query Separation) and reactive programming paradigms.
The library provides a bridge between push-based and pull-based rendering strategies, enabling granular control over re-rendering in pull-based frameworks like React and Vue while maintaining the efficiency of push-based frameworks like Angular.
Motivation
In modern frontend development, there's a significant gap in libraries that can effectively manage data and automate the processes of managing, caching, and invalidating data in frontend applications while fitting seamlessly into the MVVM architectural pattern. Most existing solutions either:
- Don't follow any software architectural patterns principles
- Lack proper single responsibility
- Don't provide granular control over re-rendering
- Are framework-specific rather than framework-independent
We created Reactive Query to address these challenges by providing a specialized library that handles all logic related to data manipulation in the Model part of MVVM or any MV*.
Bridge Between Push and Pull Strategies
Modern frontend frameworks use different rendering strategies:
Push-based (Angular): The framework automatically detects changes and re-renders components when data changes.
Pull-based (React/Vue): Components must explicitly request re-renders when their state changes.
Reactive Query bridges this gap by providing reactive observables that can be easily connected to pull-based frameworks. For example, in React, you can pipe and map changes to specific object keys, triggering setState only when relevant data changes:
// Instead of re-rendering on any data change
userModel.query().subscribe(setUserData);
// You can be granular and only re-render when specific fields change
userModel.query().pipe(
distinctUntilChanged((prev, next) => prev.places.length === next.places.length)
).subscribe(setPlaces);CQS Pattern Implementation
We implemented the Command Query Separation (CQS) pattern to handle different types of data operations:
- Queries: Read operations that don't modify state of the software and just need to be cached and refresh the data in some scenarios.
- Commands: Write operations that modify software state
This separation allows for better performance, caching strategies, and state management. For more information about CQS, see Command Query Separation.
Reactive Programming with RxJS
To provide subscribing capabilities and maintain framework agnosticism, we use the reactive programming paradigm with RxJS. This enables:
- Automatic subscription management
- Powerful data transformation operators
- Framework-independent state management
- Efficient change detection and propagation
Features
- 🏗️ MVVM Architecture - Designed specifically for the Model part of MVVM
- 🔄 CQS Pattern - Clear separation between Commands and Queries
- ⚡ Reactive Programming - Built on RxJS for real-time state updates
- 💾 Smart Caching - Automatic caching with configurable stale times
- 🔄 Retry Mechanism - Built-in retry logic for failed operations
- 🎯 TypeScript Support - Full TypeScript support with type safety
- 📦 Lightweight - Minimal bundle size with zero dependencies (except RxJS)
- 🔧 Framework Agnostic - Works with any frontend framework
- 🎛️ Granular Control - Fine-grained control over re-rendering
- 🔌 Extensible - Easy to extend with custom stores and events
Installation
npm install reactive-query rxjs
# or
yarn add reactive-query rxjs
# or
pnpm add reactive-query rxjsArchitecture Overview
Reactive Query follows a clear architectural pattern:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Query Models │ │ Command Models │ │ Stores │
│ │ │ │ │ │
│ • ReactiveQuery │ │ • ReactiveCmd │ │ • Query Vault │
│ • Caching │ │ • Mutations │ │ • Command Store │
│ • Parameters │ │ • Parameters │ │ • Events │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
│
┌─────────────────┐
│ RxJS Streams │
│ │
│ • Observables │
│ • Subscriptions │
│ • Operators │
└─────────────────┘Query Models
Query Models handle read operations and implement intelligent caching strategies.
Thinking with Query Models
Query Models are designed around the concept of parameterized queries that return cached results. Think of them as smart data fetchers that:
- Cache by parameters - Different parameters create different cache entries
- Auto-refresh stale data - Automatically fetch fresh data when cache expires
- Handle loading states - Provide loading, error, and success states
- Retry on failure - Automatically retry failed requests
Vault and Store
Vault: A collection of stores indexed by hashed parameters. Think of it as a cache container.
Store: Individual cache entries containing data, loading states, and metadata.
// Vault structure
{
"user_123": { data: User, isLoading: false, isFetched: true, ... },
"user_456": { data: User, isLoading: true, isFetched: false, ... },
"products_filters": { data: Product[], isLoading: false, isFetched: true, ... }
}Query and Refresh
Query: The public method that returns an observable of query results.
Refresh: The protected method you implement to fetch data from your API.
class UserQueryModel extends ReactiveQueryModel<User> {
protected async refresh(userId: number): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
}
}
// Usage
const userModel = new UserQueryModel();
const user$ = userModel.query(123); // Observable<User>Key Hashing and Parameters
Parameters are automatically hashed to create cache keys. The library provides intelligent hashing that:
- Handles primitive values (strings, numbers, booleans)
- Sorts object keys for consistent hashing (Just one layer to avoid heavy time complexity. You can overwrite hashing logics for custom algorithms)
- Handles arrays and nested objects
- Supports custom hashing algorithms
// These all create the same hash key
userModel.query({ id: 123, include: 'profile' });
userModel.query({ include: 'profile', id: 123 });
// Different parameters create different cache entries
userModel.query(123); // Key: "123"
userModel.query(456); // Key: "456"
userModel.query({ id: 123 }); // Key: '{"id":123}'Configuration
Query Models support various configuration options:
class UserQueryModel extends ReactiveQueryModel<User> {
protected get configs() {
return {
maxRetryCall: 3, // Retry failed requests 3 times
cachTime: 5 * 60 * 1000, // Cache for 5 minutes
emptyVaultOnNewValue: false, // Keep old cache when new data arrives
initStore: {
key: 'default',
value: { id: 0, name: 'Loading...' },
staleTime: 60 * 1000
}
};
}
}Query API Reference
Exported Types
// Main response type for queries
type QueryResponse<DATA> = {
data?: DATA;
isLoading: boolean;
isFetching: boolean;
isFetched: boolean;
error?: unknown;
staled: boolean;
staleTime?: number;
lastFetchedTime?: number;
};
// Base store type
type BaseReactiveStore<DATA> = {
data: DATA;
isLoading: boolean;
isFetching: boolean;
isFetched: boolean;
error?: unknown;
staled: boolean;
staleTime?: number;
lastFetchedTime?: number;
};
// Vault type for multiple stores
type ReactiveQueryVault<DATA, EVENTS = undefined> = {
store$: Observable<{ [key: string]: BaseReactiveStore<DATA> }>;
} & QueryVaultEvents<DATA> & EVENTS;Protected Methods (Can be overridden)
// Override to implement your data fetching logic
protected abstract refresh(params?: unknown): Promise<DATA>;
// Override for custom parameter hashing
protected getHashedKey(params?: unknown): string;
// Override for custom configuration
protected get configs(): {
maxRetryCall: number;
cachTime: number;
emptyVaultOnNewValue: boolean;
initStore?: {
key: string;
value: DATA;
staleTime?: number;
};
};Public Methods
// Main query method
query(params?: unknown, configs?: { staleTime?: number }): Observable<QueryResponse<DATA>>
// Store management
get storeHandler(): {
invalidate(): void;
invalidateByKey(params?: unknown): void;
resetStore(params?: unknown): void;
resetVault(): void
}
// Utility methods
isSameBaseData(prev: QueryResponse<DATA>, curr: QueryResponse<DATA>): boolean;Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| maxRetryCall | number | 1 | Maximum retry attempts for failed requests |
| cachTime | number | 3 * 60 * 1000 | Default cache time in milliseconds |
| emptyVaultOnNewValue | boolean | false | Clear vault when new data arrives |
| initStore | object | undefined | Initial store configuration |
Command Models
Command Models handle write operations (create, update, delete) and manage parameter state.
Understanding Mutate
The mutate method is the core of Command Models. It handles write operations and manages the command lifecycle:
class CreateUserCommandModel extends ReactiveCommandModel<CreateUserParams, User> {
async mutate(params: CreateUserParams): Promise<User> {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(params)
});
return response.json();
}
}Store Architecture
Unlike Query Models, Command Models use a single store instead of a vault because:
- No parameter-based caching - Commands don't need to cache by parameters
- Single state management - One set of parameters per command
- Immediate execution - Commands execute immediately, not on demand
// Command store structure
{
isLoading: boolean;
params: Partial<PARAMS>;
// ... extended store properties
}Parameter Management
Command Models provide built-in parameter management:
// Get current parameters
const params = commandModel.getParams();
// Update parameters
commandModel.updateModificationStore({ name: 'John', email: '[email protected]' });
// Get specific parameter
const name = commandModel.getModificationValueByKey('name');
// Subscribe to parameter changes
commandModel.subscribeToParam().subscribe(({ params, isLoading }) => {
console.log('Parameters changed:', params);
});Store Extension
Command Models support extended stores and custom events:
class ExtendedCommandModel extends ReactiveCommandModel<
UserParams,
User,
{ validationErrors: string[] },
{ onValidationError: (errors: string[]) => void }
> {
protected initExtendedStore() {
return {
initExtendedStore: { validationErrors: [] },
extendedEvents: (store$) => ({
onValidationError: (errors: string[]) => {
store$.next({
...store$.value,
validationErrors: errors
});
}
})
};
}
}Command API Reference
Exported Types
// Command response type
type CommandModelSubscribeResponse<PARAMS> = {
params: Partial<PARAMS>;
isLoading: boolean;
};
// Base command store
type BaseReactiveCommandStore<PARAMS, EXTENDED_STORE> = {
isLoading: boolean;
params: Partial<PARAMS>;
} & EXTENDED_STORE;
// Command store with events
type ReactiveCommandStore<PARAMS, EXTENDED_STORE, EXTENDED_EVENTS> = {
store$: BehaviorSubject<BaseReactiveCommandStore<PARAMS, EXTENDED_STORE>>;
} & BaseReactiveCommandEvents<PARAMS, EXTENDED_STORE> & EXTENDED_EVENTS;Protected Methods (Can be overridden)
// Override to implement your mutation logic
abstract mutate(...args: any[]): Promise<RESPONSE>;
// Override for initial parameters
getInitialParams(): PARAMS;
// Override for extended store and events
protected initExtendedStore(): {
initExtendedStore?: EXTENDED_STORE;
extendedEvents?: (store$: BehaviorSubject<BaseReactiveCommandStore<PARAMS, EXTENDED_STORE>>) => EXTENDED_EVENTS;
};Public Methods
// Subscribe to store changes
subscribeToParam(): Observable<CommandModelSubscribeResponse<PARAMS>>;
// Parameter management
getModificationValueByKey<T extends keyof PARAMS>(key: T): PARAMS[T] | undefined;
updateModificationStore(params: Partial<PARAMS>): void;
getParams(): PARAMS;
getStore(): BaseReactiveCommandStore<PARAMS, EXTENDED_STORE>;
// State management
updateIsLoading(isLoading: boolean): void;
resetStore(): void;Adapters
React Integration
For seamless React integration, we provide a dedicated React adapter library: reactive-query-react
Using the React Adapter (Recommended)
npm install reactive-query-reactimport React, { useRef } from 'react';
import { useRXQuery } from 'reactive-query-react';
import { ReactiveQueryModel } from 'reactive-query';
class TodoQueryModel extends ReactiveQueryModel<Todo[]> {
protected async refresh(): Promise<Todo[]> {
const response = await fetch('/api/todos');
return response.json();
}
}
function TodoList() {
const todoModel = useRef(new TodoQueryModel()).current;
const queryData = useRXQuery(todoModel.query);
if (queryData.loading) {
return <p>Loading...</p>;
}
if (queryData.error) {
return <p>Error: {queryData.error.message}</p>;
}
return (
<ul>
{queryData.data?.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}Svelte
For now we don't have any adapter for Svelte but to see an example of how to use it with Svelte You can check this gist
Contributing
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
License
This project is licensed under the MIT License - see the LICENSE file for details.
