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.
- Reactive Query
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.
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*.
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);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.
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
- 🏗️ 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
npm install reactive-query rxjs
# or
yarn add reactive-query rxjs
# or
pnpm add reactive-query rxjsReactive 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 handle read operations and implement intelligent caching strategies.
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: 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: 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>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}'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
}
};
}
}// 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;// 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;
};
};// 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;| 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 handle write operations (create, update, delete) and manage parameter state.
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();
}
}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
}Command Models provide built-in parameter management:
// Get current parameters
const params = commandModel.getParams();
// Update parameters
commandModel.updateModificationStore({ name: 'John', email: 'john@example.com' });
// Get specific parameter
const name = commandModel.getModificationValueByKey('name');
// Subscribe to parameter changes
commandModel.subscribeToParam().subscribe(({ params, isLoading }) => {
console.log('Parameters changed:', params);
});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 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;// 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;
};// 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;For seamless React integration, we provide a dedicated React adapter library: reactive-query-react
Note: This library act as the Model in the MV* architectures, highly suggested to be used beside of ReactVVM which acts as the ViewModel and View in the MVVM. With these two approaches you can have solid MVVM architecture in your react application in any scale of projects.
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>
);
}Note: This library act as the Model in the MV* architectures, highly suggested to be used beside of ReactVVM which acts as the ViewModel and View in the MVVM. With these two approaches you can have solid MVVM architecture in your react application in any scale of projects.
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
- 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
This project is licensed under the MIT License - see the LICENSE file for details.