Skip to content

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 ​

javascript
// 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 ​

javascript
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:

javascript
// 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:

javascript
// ✅ 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:

javascript
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
javascript
// 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
javascript
// 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.

javascript
// 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.

javascript
// 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
javascript
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 ​

javascript
// 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 ​

javascript
// 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 ​

javascript
// 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:

javascript
// 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
javascript
// 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:

  1. Hydrates from IndexedDB - Loads cached data immediately for instant UI
  2. Syncs with backend - Fetches latest data and updates cache
  3. 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
javascript
// 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"
javascript
// 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:

  1. Ground Truth: The confirmed data from your backend
  2. Operations: Pending optimistic changes that haven't been confirmed yet
javascript
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:

  1. Starts with ground truth - the confirmed data from the backend
  2. Applies optimistic operations - create/update/delete operations that are pending
  3. 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:

  1. Optimistic Phase: Changes appear immediately in your UI
  2. API Phase: The operation is sent to your backend
  3. Confirmation Phase: Server response updates the final state
javascript
// 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:

javascript
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 ​

javascript
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 ​

javascript
try {
  const user = await User.objects.get({ id: 123 });
} catch (error) {
  if (error.name === 'DoesNotExist') {
    // Handle missing user
  }
}

Pagination ​

javascript
// 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 });
javascript
// 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 ​

javascript
// 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 ​

javascript
// 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 ​

javascript
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 ​

javascript
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 ​

javascript
// 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.