Live Queryset Behavior
What are Live Querysets?
Live querysets are reactive collections that automatically stay synchronized with your backend data. When you create a queryset in StateZero, you get a "live" version that updates in real-time as data changes, without needing to manually refetch or manage state updates.
Think of them as smart arrays that know how to keep themselves up-to-date.
Key Benefits
- Automatic Updates: Your UI stays in sync without manual intervention
- Optimistic Updates: Changes appear immediately, then confirm with the server
- Shared State: Multiple components automatically share the same live data
- Performance: Intelligent caching reduces unnecessary API calls
Basic Usage
Creating Live Querysets
// Get all users - returns immediately with cached data
const users = User.objects.all();
// Filtered queryset
const activeUsers = User.objects.filter({ active: true });
// The queryset behaves like a normal array
console.log(users.length); // Current count
users.forEach(user => { ... }); // Iterate through users
const firstUser = users[0]; // Access by index
Framework Integration
Vue.js
import { useQueryset } from 'statezero/vue';
export default {
setup() {
// Reactive queryset that triggers re-renders
const users = useQueryset(() => User.objects.all());
return { users };
}
};
Direct Usage Pattern
StateZero works best when you use live querysets directly rather than copying or transforming the data. Because the ORM is so expressive, lots of local processing usually isn't necessary - you can get the data you need through queryset methods. The querysets themselves are reactive and optimized for direct use:
// Preferred: Use querysets directly
const users = User.objects.all();
const activeUsers = User.objects.filter({ active: true });
// In your template, reference them directly (gives unpaginated data)
// <div v-for="user in users" :key="user.id">{{ user.name }}</div>
const firstUser = users[0]; // Direct indexing
// For pagination, use serializer options with .all() or .fetch()
const paginatedUsers = User.objects.all().all({ limit: 20, offset: 0 });
// or
const userPage = await User.objects.all().fetch({ limit: 20, offset: 0 });
// Understanding count vs length
const userCount = users.count(); // Total count from backend (even if paginated)
const loadedLength = users.length; // Length of what you've retrieved/loaded
// If you need transformations, use computed properties to maintain reactivity
const userNames = computed(() => users.map(user => user.name));
const reactiveCount = computed(() => users.length);
// Filtering: Prefer backend filtering over frontend filtering
const activeUsers = User.objects.filter({ active: true }); // ✅ Backend filtering
const inactiveUsers = computed(() =>
users.filter(user => !user.active) // ❌ Frontend filtering - avoid when possible
);
// Frontend filtering should only be used for:
// - Additional computed fields not available in backend
// - Complex UI-specific transformations
const usersWithDisplayName = computed(() =>
users.map(user => ({
...user,
displayName: `${user.first_name} ${user.last_name}` // UI-specific computed field
}))
);
// Avoid: Copying or transforming into other structures
const userArray = [...users]; // Unnecessary copy - loses reactivity
const userList = users.map(u => u); // Unnecessary transformation - loses reactivity
const { 0: firstUser, length } = users; // Destructuring loses reactivity
Why direct usage is better:
- Performance: No unnecessary data copying or transformation
- Reactivity: Framework adapters are optimized for the queryset structure
- Memory: Single source of truth reduces memory usage
- Consistency: All components see the same live data automatically
Reactivity Warning
Important: Destructuring or spreading querysets breaks reactivity. Always reference the queryset directly in your templates:
// ✅ Good - maintains reactivity
<template>
<div v-for="user in users" :key="user.id">
{{ user.name }}
</div>
<p>Total: {{ users.length }}</p>
</template>
// ❌ Bad - loses reactivity
<template>
<div v-for="user in userArray" :key="user.id">
{{ user.name }}
</div>
<p>Total: {{ userCount }}</p>
</template>
const userArray = [...users]; // Static copy, won't update
const userCount = users.length; // Static value, won't update
Once wrapped with useQueryset(), everything downstream is reactive unless you explicitly break it:
const users = useQueryset(() => User.objects.all());
// ✅ These maintain reactivity
const firstUser = users[0]; // Individual models stay reactive
const userName = firstUser.name; // Model properties are reactive
const userPosts = firstUser.posts; // Related data stays reactive
// ✅ Slicing specific models is fine - they stay reactive
const topUsers = users.slice(0, 5); // Each model in slice is reactive
const lastUser = users[users.length - 1]; // Still reactive
// ❌ These break reactivity
const usersCopy = [...users]; // Spreading breaks it
const { name } = firstUser; // Destructuring breaks it
State Management
When using StateZero, you typically don't need additional state management libraries like Pinia, Vuex, or Redux. StateZero provides:
- Global state: Live querysets are automatically shared across components
- Reactivity: Built-in reactive updates when data changes
- Caching: Intelligent caching and synchronization
- Real-time updates: Automatic sync with backend changes
// Component A
const users = User.objects.all();
// Component B (elsewhere in your app)
const sameUsers = User.objects.all(); // Same live data automatically
// No need for:
// - store.dispatch('fetchUsers')
// - this.$store.state.users
// - useState/useReducer patterns
Thin Frontend Architecture
StateZero fundamentally changes how you architect SPAs. Your frontend becomes much thinner than traditional approaches:
Traditional SPA:
- Complex state management (actions, reducers, stores)
- Manual data synchronization
- Local state mutations and side effects
- Data transformation layers
With StateZero:
- Interact directly with the ORM
- State essentially evaporates as a concern
- Data flows automatically
- Focus on UI and user experience
// Traditional: Complex state management
store.dispatch('fetchUsers');
store.dispatch('createUser', userData);
const users = computed(() => store.state.users);
// StateZero: Direct ORM interaction
const users = User.objects.all();
User.objects.create(userData); // users updates automatically
The key mental model: Your template code should feel like a reactive function of your backend state. When backend data changes (whether from your actions or external updates), your UI automatically reflects those changes without any manual intervention.
// Your component becomes a pure function of backend state
const users = User.objects.filter({ active: true });
const posts = Post.objects.filter({ author__in: users });
// Template renders: f(backend_state) → UI
// Changes to users or posts anywhere automatically update your UI
StateZero provides a unified state layer accessed through the ORM, eliminating most frontend state concerns. Your components simply declare what data they need and render it - StateZero handles all the synchronization, caching, and reactivity automatically.
How Live Querysets Work
Immediate Access + Eventual Consistency
Live querysets follow a unique pattern: they return immediately with the best available data, but also resolve as promises when server confirmation arrives.
// Returns immediately with cached/optimistic data
const users = User.objects.all();
console.log(users.length); // Shows current count right away
// Also awaitable for server-confirmed data
const confirmedUsers = await users;
console.log(confirmedUsers.length); // Shows confirmed count from server
Real-Time Synchronization
Live querysets automatically update when:
- Another user modifies data
- You perform operations that affect the queryset
- Related data changes that impacts your filters
const activeUsers = User.objects.filter({ active: true });
// If someone else activates a user, activeUsers automatically updates
// If you deactivate a user, they're automatically removed from activeUsers
// Your UI re-renders automatically with the new data
Optimistic Updates
When you create, update, or delete objects, your live querysets update immediately for responsive UIs, then confirm with the server.
Creating Objects
// Create a new user
const newUser = User.objects.create({
name: "Alice",
email: "alice@example.com"
});
// Any live querysets that would include this user update immediately
const allUsers = User.objects.all(); // Now includes Alice instantly
// The creation is also a promise for the confirmed result
newUser.then(confirmedUser => {
console.log('Server assigned ID:', confirmedUser.id);
});
Updating Objects
// Update existing user
const user = await User.objects.get({ id: 123 });
const updatedUser = user.update({ name: "New Name" });
// Any live querysets containing this user show the change immediately
// The update confirms with the server in the background
Deleting Objects
// Delete user
const user = await User.objects.get({ id: 123 });
const result = user.delete();
// User immediately disappears from all live querysets
// Deletion confirms with server in the background
Shared State
Multiple components that use the same queryset automatically share state:
// Component A
const users = User.objects.all();
// Component B (elsewhere in your app)
const sameUsers = User.objects.all(); // Same live data
// When Component A creates a user, Component B sees it immediately
StateZero automatically detects when querysets are equivalent and shares the underlying live data.
Caching and Persistence
StateZero provides intelligent caching and persistence to ensure fast performance and offline capability.
IndexedDB Storage
StateZero automatically persists data to IndexedDB (the browser's local database) for:
- Queryset results: Cached results from API calls
- Model instances: Individual object data
- Metric values: Cached aggregation results
// First load - fetches from server and caches in IndexedDB
const users = User.objects.all();
// Page refresh - hydrates immediately from IndexedDB, then syncs with server
const users = User.objects.all(); // Shows cached data instantly
Cache Hydration
When your app starts, StateZero:
- Hydrates from IndexedDB - Loads cached data immediately for instant UI
- Syncs with backend - Fetches latest data and updates cache
- Merges optimistic operations - Applies any pending local changes
This provides the best of both worlds: instant loading from cache plus always-current data.
Automatic Cache Management
StateZero handles cache management automatically:
- Intelligent updates: Only caches data that's actively used
- Semantic keys: Uses queryset structure to determine cache keys
- Automatic cleanup: Removes unused cache entries
- Error recovery: Automatically resets corrupted caches
// These share the same cache automatically
const users1 = User.objects.filter({ active: true });
const users2 = User.objects.filter({ active: true }); // Same semantic key = shared cache
// These use different caches
const activeUsers = User.objects.filter({ active: true });
const inactiveUsers = User.objects.filter({ active: false }); // Different semantic key
Fast Reloads and Native Feel
With IndexedDB persistence, StateZero provides a native app experience:
- Instant startup: App shows data immediately on reload, no loading spinners
- Seamless transitions: Navigation feels instant with cached data
- Progressive enhancement: UI appears immediately, then updates with fresh data
- Native app feel: Eliminates the "web app loading delay"
// User refreshes page
// 1. UI renders instantly with cached data (no loading state needed)
// 2. Fresh data syncs in background and updates UI seamlessly
// 3. User sees content immediately, then sees any updates
const users = User.objects.all(); // Shows cached data instantly on refresh
The cache transforms typical web app behavior (load → fetch → render) into native app behavior (render immediately → sync in background).
How Live Querysets Render
Understanding the rendering mechanics helps explain the live behavior you see in your application.
Ground Truth + Operations Model
Live querysets work by combining two sources of data:
- Ground Truth: The confirmed data from your backend
- Operations: Pending optimistic changes that haven't been confirmed yet
const users = User.objects.all();
// What you see in the UI = Ground Truth + Optimistic Operations
// - Ground truth: [Alice, Bob, Charlie] (confirmed from server)
// - Pending operation: Create "David" (optimistic)
// - Rendered result: [Alice, Bob, Charlie, David] (immediately visible)
When you access the live queryset (through users.length
, users[0]
, iteration, etc.), StateZero:
- Starts with ground truth - the confirmed data from the backend
- Applies optimistic operations - create/update/delete operations that are pending
- Returns the combined result - what you see reflects both confirmed and optimistic data
How Operations Work
Understanding how StateZero handles operations helps explain the live behavior you see in your application.
The Operation Lifecycle
When you perform an operation (create, update, delete), StateZero follows a three-phase process:
- Optimistic Phase: Changes appear immediately in your UI
- API Phase: The operation is sent to your backend
- Confirmation Phase: Server response updates the final state
// 1. Optimistic: User appears in UI immediately
const newUser = User.objects.create({ name: "Alice" });
// 2. API: Request sent to backend in background
// 3. Confirmation: Real data replaces optimistic data
const confirmedUser = await newUser;
Operation Types
StateZero tracks different types of operations:
- CREATE: Adding new objects
- UPDATE: Modifying existing objects
- DELETE: Removing objects
- UPDATE_INSTANCE: Updating a specific instance
- DELETE_INSTANCE: Deleting a specific instance
Smart Updates
Operations automatically affect related live querysets:
const allUsers = User.objects.all();
const activeUsers = User.objects.filter({ active: true });
// Create active user
User.objects.create({ name: "Bob", active: true });
// Both querysets update automatically:
// - allUsers gets Bob added
// - activeUsers gets Bob added (because active: true)
// Deactivate Bob
bob.update({ active: false });
// Both querysets update automatically:
// - allUsers keeps Bob (still exists)
// - activeUsers removes Bob (no longer active: true)
Common Patterns
Loading States
const users = User.objects.all();
// Check if still loading initial data
if (users.length === 0 && users.isOptimistic) {
return <LoadingSpinner />;
}
return <UserList users={users} />;
Error Handling
try {
const user = await User.objects.get({ id: 123 });
} catch (error) {
if (error.name === 'DoesNotExist') {
// Handle missing user
}
}
Pagination
// Load first page with serializer options
const users = User.objects.all().all({ limit: 20 });
// Load more with offset
const moreUsers = User.objects.all().all({ limit: 20, offset: 20 });
// You can also pass serializer options to fetch()
const users = await User.objects.all().fetch({ limit: 20, offset: 20 });
Related Data
// Automatically loads related data
const posts = Post.objects.select_related(['author']).all();
// Access related data without additional queries
posts.forEach(post => {
console.log(post.author.name); // Already loaded
});
Best Practices
Use Specific Filters
// Good - specific queryset
const todaysPosts = Post.objects.filter({
created_at__date: new Date().toISOString().split('T')[0]
});
// Avoid - overly broad queryset
const allPosts = Post.objects.all(); // Might be huge
Leverage Shared State
// Good - reuse the same queryset structure
const getUserPosts = (userId) => Post.objects.filter({ author_id: userId });
// In multiple components
const userPosts = getUserPosts(123); // Shared automatically
Handle Edge Cases
const users = User.objects.filter({ role: 'admin' });
// Always check for empty results
if (users.length === 0) {
return <EmptyState message="No admins found" />;
}
Limitations & Considerations
Memory Usage
Live querysets keep data in memory for fast access. Very large datasets should use pagination.
Network Connectivity
Optimistic updates require network connectivity. If the network request fails, the optimistic changes are reverted and an error is thrown.
Concurrent Modifications
If multiple users modify the same data simultaneously, server state wins and optimistic updates are corrected.
Debugging
Inspect Queryset State
const users = User.objects.all();
console.log(users.length); // Current count
console.log(users.isOptimistic); // Whether still loading
console.log(users.queryset); // Underlying queryset object
Monitor Updates
// Log when querysets update (development only)
const users = User.objects.all();
users.addEventListener('update', () => {
console.log('Users updated:', users.length);
});
Live querysets make building reactive UIs simple by handling all the complexity of real-time data synchronization behind the scenes. You write code as if working with normal arrays, but get the benefits of live, shared, optimistically-updated data automatically.