Handling Side Effects
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.
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 { useQuantaStore } from '@quantajs/react';
function UserProfile() {
const user = useQuantaStore(userStore, store => store.user);
const isLoading = useQuantaStore(userStore, store => store.isLoading);
// 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
userStore.saveDraft();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}
}, [user]);
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