Handling Side Effects
These examples use `createStore`:
createStore builds a store instance immediately and is still fully supported.
In application code prefer defineStore, which
returns an accessor you call — it is safe at module scope on a server and needs
no container passed around. Everything on this page applies to both.
Side effects are operations that interact with the outside world, such as API calls, DOM manipulation, timers, and file system operations. QuantaJS provides several ways to handle side effects effectively.
Async Actions Come With a Lifecycle
Before reaching for a watcher or a hand-rolled flag: every action already
carries pending, error and abort(), and this.$signal inside the action
is an AbortSignal scoped to that call.
export const useUsersStore = defineStore('users', {
state: () => ({ list: [] }),
actions: {
async load(id) {
const res = await fetch(`/api/users/${id}`, { signal: this.$signal });
this.list = await res.json();
},
},
});
const users = useUsersStore();
users.load.pending; // boolean, reactive
users.load.error; // Error | null — cleared at the start of every call
users.load.abort(); // cancels every in-flight call
That covers the most common side effect — "fetch something and show a spinner"
— without a isLoading field, an error field, or a finally you can forget
to write. The rest of this page is for the effects it does not cover:
reacting to a state change, syncing to an external system, timers, sockets.
See Async Actions for the full surface.
Using Watch for Side Effects
The watch function is perfect for handling side effects that need to respond to state changes:
import { reactive, watch } from '@quantajs/core';
const userStore = reactive({
user: null,
userId: null,
isLoading: false,
});
// Watch for user ID changes and fetch user data
watch(() => userStore.userId, async (userId) => {
if (userId) {
userStore.isLoading = true;
try {
const response = await fetch(`/api/users/${userId}`);
userStore.user = await response.json();
} catch (error) {
console.error('Failed to fetch user:', error);
} finally {
userStore.isLoading = false;
}
} else {
userStore.user = null;
}
});
// Watch for user changes and update page title
watch(() => userStore.user?.name, (userName) => {
if (userName) {
document.title = `Profile - ${userName}`;
} else {
document.title = 'User Profile';
}
});
// Watch for loading state changes
watch(() => userStore.isLoading, (isLoading) => {
const spinner = document.getElementById('spinner');
if (spinner) {
spinner.style.display = isLoading ? 'block' : 'none';
}
});
// Trigger the side effect
userStore.userId = 123;
Side Effects in Store Actions
Store actions can handle side effects directly:
import { createStore } from '@quantajs/core';
const todoStore = createStore('todos', {
state: () => ({
todos: [],
isLoading: false,
error: null,
}),
actions: {
async fetchTodos() {
this.isLoading = true;
this.error = null;
try {
const response = await fetch('/api/todos');
if (!response.ok) {
throw new Error('Failed to fetch todos');
}
this.todos = await response.json();
} catch (error) {
this.error = error.message;
console.error('Failed to fetch todos:', error);
} finally {
this.isLoading = false;
}
},
async addTodo(text) {
this.isLoading = true;
try {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!response.ok) {
throw new Error('Failed to add todo');
}
const newTodo = await response.json();
this.todos.push(newTodo);
} catch (error) {
console.error('Failed to add todo:', error);
throw error; // Re-throw to let UI handle it
} finally {
this.isLoading = false;
}
},
async toggleTodo(id) {
const todo = this.todos.find(t => t.id === id);
if (!todo) return;
const originalDone = todo.done;
todo.done = !todo.done; // Optimistic update
try {
const response = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done: todo.done }),
});
if (!response.ok) {
todo.done = originalDone; // Revert on failure
throw new Error('Failed to update todo');
}
} catch (error) {
todo.done = originalDone; // Revert on failure
console.error('Failed to toggle todo:', error);
}
},
},
});
Debounced Side Effects
Use debouncing for expensive operations like search:
const searchStore = reactive({
query: '',
results: [],
isLoading: false,
});
let searchTimeout;
watch(() => searchStore.query, (query) => {
clearTimeout(searchTimeout);
if (query.length > 2) {
searchTimeout = setTimeout(async () => {
searchStore.isLoading = true;
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
searchStore.results = await response.json();
} catch (error) {
console.error('Search failed:', error);
searchStore.results = [];
} finally {
searchStore.isLoading = false;
}
}, 300); // 300ms delay
} else {
searchStore.results = [];
}
});
Local Storage Synchronization
Sync state with localStorage for persistence:
const settingsStore = reactive({
theme: 'light',
language: 'en',
notifications: {
email: true,
push: false,
},
});
// Load initial state from localStorage
const savedTheme = localStorage.getItem('theme');
const savedLanguage = localStorage.getItem('language');
const savedNotifications = localStorage.getItem('notifications');
if (savedTheme) settingsStore.theme = savedTheme;
if (savedLanguage) settingsStore.language = savedLanguage;
if (savedNotifications) {
try {
settingsStore.notifications = JSON.parse(savedNotifications);
} catch (error) {
console.error('Failed to parse saved notifications:', error);
}
}
// Watch for changes and sync to localStorage
watch(() => settingsStore.theme, (theme) => {
localStorage.setItem('theme', theme);
});
watch(() => settingsStore.language, (language) => {
localStorage.setItem('language', language);
});
watch(() => settingsStore.notifications, (notifications) => {
localStorage.setItem('notifications', JSON.stringify(notifications));
}, { deep: true });
WebSocket Connections
Handle real-time updates with WebSocket:
const chatStore = reactive({
messages: [],
isConnected: false,
socket: null,
});
let reconnectTimeout;
function connectWebSocket() {
if (chatStore.socket) {
chatStore.socket.close();
}
chatStore.socket = new WebSocket('ws://localhost:8080/chat');
chatStore.socket.onopen = () => {
chatStore.isConnected = true;
console.log('WebSocket connected');
};
chatStore.socket.onmessage = (event) => {
const message = JSON.parse(event.data);
chatStore.messages.push(message);
};
chatStore.socket.onclose = () => {
chatStore.isConnected = false;
console.log('WebSocket disconnected');
// Attempt to reconnect after 5 seconds
reconnectTimeout = setTimeout(connectWebSocket, 5000);
};
chatStore.socket.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
// Connect when user logs in
watch(() => userStore.user, (user) => {
if (user) {
connectWebSocket();
} else {
if (chatStore.socket) {
chatStore.socket.close();
}
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
}
}
});
// Cleanup on unmount
function cleanup() {
if (chatStore.socket) {
chatStore.socket.close();
}
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
}
}
Timer-Based Side Effects
Handle timers and intervals:
const notificationStore = reactive({
notifications: [],
autoHideDelay: 5000,
});
// Auto-hide notifications after delay
watch(() => notificationStore.notifications, (notifications) => {
notifications.forEach(notification => {
if (!notification.timer && notification.autoHide !== false) {
notification.timer = setTimeout(() => {
notificationStore.removeNotification(notification.id);
}, notificationStore.autoHideDelay);
}
});
}, { deep: true });
// Add notification action
function addNotification(message, type = 'info', autoHide = true) {
const notification = {
id: Date.now(),
message,
type,
autoHide,
timestamp: new Date(),
};
notificationStore.notifications.push(notification);
}
// Remove notification action
function removeNotification(id) {
const notification = notificationStore.notifications.find(n => n.id === id);
if (notification && notification.timer) {
clearTimeout(notification.timer);
}
notificationStore.notifications = notificationStore.notifications.filter(
n => n.id !== id
);
}
Error Handling
Implement robust error handling for side effects:
const errorStore = reactive({
errors: [],
globalError: null,
});
function handleError(error, context = '') {
const errorInfo = {
id: Date.now(),
message: error.message || error,
context,
timestamp: new Date(),
stack: error.stack,
};
errorStore.errors.push(errorInfo);
errorStore.globalError = errorInfo;
// Log to external service
logErrorToService(errorInfo);
// Show user-friendly notification
showErrorNotification(errorInfo.message);
}
async function logErrorToService(errorInfo) {
try {
await fetch('/api/errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(errorInfo),
});
} catch (error) {
console.error('Failed to log error:', error);
}
}
function showErrorNotification(message) {
// Implementation depends on your UI framework
console.error('Error:', message);
}
// Use in store actions
const userStore = createStore('user', {
state: () => ({ user: null }),
actions: {
async login(credentials) {
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new Error('Login failed');
}
this.user = await response.json();
} catch (error) {
handleError(error, 'user.login');
throw error; // Re-throw to let UI handle it
}
},
},
});
React Integration
In React applications, combine QuantaJS side effects with React hooks:
import { useEffect } from 'react';
import { useQuantaValue, useQuantaActions } from '@quantajs/react';
import { useUserStore } from './stores/user';
function UserProfile() {
const user = useQuantaValue(useUserStore, (s) => s.user);
// The action's own lifecycle — no `isLoading` field in state.
const isLoading = useQuantaValue(useUserStore, (s) => s.load.pending);
const store = useQuantaActions(useUserStore);
// React-specific side effects
useEffect(() => {
if (user) {
// Update document title
document.title = `Profile - ${user.name}`;
// Track analytics
analytics.track('profile_viewed', { userId: user.id });
// Set up event listeners
const handleBeforeUnload = () => {
// Save unsaved changes
store.saveDraft();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}
}, [user, store]);
if (isLoading) return <div>Loading...</div>;
if (!user) return <div>Please log in</div>;
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
</div>
);
}
Best Practices
1. Separate Concerns
Keep side effects separate from pure state logic:
// Good: Side effects in actions
const store = createStore('app', {
state: () => ({ data: null }),
actions: {
async fetchData() {
// Side effect here
const response = await fetch('/api/data');
this.data = await response.json();
},
},
});
// Good: Side effects in watchers
watch(() => store.data, (data) => {
// Side effect here
localStorage.setItem('data', JSON.stringify(data));
});
2. Handle Loading States
Always manage loading states for async operations:
const store = createStore('app', {
state: () => ({ data: null, isLoading: false }),
actions: {
async fetchData() {
this.isLoading = true;
try {
const response = await fetch('/api/data');
this.data = await response.json();
} finally {
this.isLoading = false;
}
},
},
});
3. Implement Error Boundaries
Handle errors gracefully:
const store = createStore('app', {
state: () => ({ data: null, error: null }),
actions: {
async fetchData() {
this.error = null;
try {
const response = await fetch('/api/data');
this.data = await response.json();
} catch (error) {
this.error = error.message;
// Log error or show notification
}
},
},
});
4. Clean Up Resources
Always clean up timers, subscriptions, and connections:
function setupSideEffects() {
const timer = setInterval(() => {
// Do something
}, 1000);
const subscription = someService.subscribe((data) => {
// Handle data
});
// Return cleanup function
return () => {
clearInterval(timer);
subscription.unsubscribe();
};
}
Learn More
- Watching State - Understanding watchers
- Managing Stores - Store management
- React Integration - React applications
- API Reference - Watch API documentation