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