Watch

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.

watch

Watches for changes in reactive state and executes a callback when changes occur.

Signature

function watch<T>(
  source: () => T,
  callback: (value: T, oldValue?: T) => void,
  options?: {
    deep?: boolean;
    immediate?: boolean;
    equals?: (value: T, oldValue: T) => boolean;
  }
): () => void;

Parameters

  • source: A function that returns the value to watch. This function is automatically tracked for dependencies.
  • callback: A function that executes when the watched value changes. Receives the new value and optionally the old value as parameters.
  • options (optional): Configuration options:
    • deep: Whether to perform deep watching (defaults to false). When true, recursively tracks all nested properties for changes.
    • immediate: Whether to invoke the callback once immediately upon setup (defaults to false).
    • equals: Decides whether a re-run produced the same value; the callback fires only when it returns false. Defaults to Object.is. Ignored with deep.

Returns

A cleanup function that stops the watcher when called.

Basic Usage

import { reactive, watch } from '@quantajs/core';

const state = reactive({
  count: 0,
  name: 'Quanta',
});

// Watch a single value (the callback runs on the next change)
const unwatchCount = watch(() => state.count, (newValue, oldValue) => {
  console.log(`Count changed from ${oldValue} to ${newValue}`);
});

// Watch another value
const unwatchName = watch(() => state.name, (newValue) => {
  console.log(`Name changed to: ${newValue}`);
});

state.count = 5; // Logs: "Count changed from 0 to 5"
state.name = 'QuantaJS'; // Logs: "Name changed to: QuantaJS"

// Stop watching when done
unwatchCount();
unwatchName();

Watching Multiple Values

Watch multiple reactive values in a single watcher:

const user = reactive({
  firstName: 'John',
  lastName: 'Doe',
  age: 25,
});

// Watch computed values
watch(() => `${user.firstName} ${user.lastName}`, (fullName) => {
  console.log(`Full name changed to: ${fullName}`);
});

// Watch derived values
watch(() => user.age >= 18, (isAdult) => {
  console.log(`User is now ${isAdult ? 'an adult' : 'a minor'}`);
});

// Watch multiple properties
watch(() => ({
  name: `${user.firstName} ${user.lastName}`,
  age: user.age,
  isAdult: user.age >= 18,
}), (userInfo) => {
  console.log('User info updated:', userInfo);
});

user.firstName = 'Jane'; // Triggers multiple watchers
user.age = 16; // Triggers age-related watchers

Watching Store State

Watch for changes in store state:

import { createStore } from '@quantajs/core';

const todoStore = createStore('todos', {
  state: () => ({
    todos: [],
    filter: 'all',
  }),
  actions: {
    addTodo(text) {
      this.todos.push({ id: Date.now(), text, done: false });
    },
    setFilter(filter) {
      this.filter = filter;
    },
  },
});

// Watch the entire todos array
watch(() => todoStore.todos, (newTodos) => {
  console.log(`Todos updated: ${newTodos.length} items`);
  localStorage.setItem('todos', JSON.stringify(newTodos));
});

// Watch specific computed values
watch(() => todoStore.todos.filter(t => t.done).length, (completedCount) => {
  console.log(`${completedCount} todos completed`);
});

// Watch filter changes
watch(() => todoStore.filter, (newFilter) => {
  console.log(`Filter changed to: ${newFilter}`);
});

todoStore.addTodo('Learn QuantaJS');
todoStore.addTodo('Build an app');
todoStore.setFilter('active');

Side Effects with Watch

Use watch for common side effects like API calls, DOM updates, and logging:

const userStore = createStore('user', {
  state: () => ({
    user: null,
    isLoading: false,
    userId: null,
  }),
  actions: {
    setUserId(id) {
      this.userId = id;
    },
  },
});

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

// Watch for authentication state
watch(() => !!userStore.user, (isAuthenticated) => {
  if (isAuthenticated) {
    console.log('User authenticated, redirecting to dashboard');
    // router.push('/dashboard');
  } else {
    console.log('User logged out, redirecting to login');
    // router.push('/login');
  }
});

userStore.setUserId(123); // Triggers user fetch

Watching Nested Objects

Watch deeply nested object changes:

const settings = reactive({
  theme: {
    mode: 'dark',
    colors: {
      primary: '#007bff',
      secondary: '#6c757d',
    },
  },
  notifications: {
    email: true,
    push: false,
  },
});

// Watch specific nested properties
watch(() => settings.theme.mode, (newMode) => {
  document.body.setAttribute('data-theme', newMode);
});

// Watch multiple nested properties
watch(() => ({
  mode: settings.theme.mode,
  primary: settings.theme.colors.primary,
}), (newSettings) => {
  console.log('Theme settings changed:', newSettings);
});

// Watch entire nested objects
watch(() => settings.notifications, (newNotifications) => {
  console.log('Notification settings updated:', newNotifications);
});

settings.theme.mode = 'light';
settings.theme.colors.primary = '#28a745';
settings.notifications.push = true;

Performance Considerations

Watch can be expensive for large objects. Use specific selectors when possible:

const largeStore = createStore('large', {
  state: () => ({
    items: Array.from({ length: 1000 }, (_, i) => ({ id: i, value: Math.random() })),
    metadata: { /* large object */ },
  }),
});

// Good: Watch specific values
watch(() => largeStore.items.length, (count) => {
  console.log(`Item count: ${count}`);
});

// Avoid: Watching entire large objects
// watch(() => largeStore.items, (items) => { ... }); // Expensive!

// Good: Watch computed summaries
watch(() => largeStore.items.filter(item => item.value > 0.5).length, (count) => {
  console.log(`${count} items above threshold`);
});

// Good: Watch specific properties
watch(() => largeStore.metadata.lastUpdated, (timestamp) => {
  console.log(`Last updated: ${timestamp}`);
});

Common Use Cases

Form Validation

const form = reactive({
  email: '',
  password: '',
  confirmPassword: '',
});

// Watch for email changes and validate
watch(() => form.email, (email) => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  const isValid = emailRegex.test(email);
  
  if (!isValid && email) {
    console.log('Invalid email format');
  }
});

// Watch for password confirmation
watch(() => [form.password, form.confirmPassword], ([password, confirmPassword]) => {
  if (password && confirmPassword && password !== confirmPassword) {
    console.log('Passwords do not match');
  }
});
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);
      } finally {
        searchStore.isLoading = false;
      }
    }, 300);
  } else {
    searchStore.results = [];
  }
});

Local Storage Sync

const appState = reactive({
  theme: 'light',
  language: 'en',
  preferences: {
    notifications: true,
    autoSave: true,
  },
});

// Sync theme to localStorage
watch(() => appState.theme, (theme) => {
  localStorage.setItem('theme', theme);
});

// Sync language to localStorage
watch(() => appState.language, (language) => {
  localStorage.setItem('language', language);
});

// Sync all preferences with deep watching
const unwatchPreferences = watch(() => appState.preferences, (preferences) => {
  localStorage.setItem('preferences', JSON.stringify(preferences));
}, { deep: true });

// Clean up when component unmounts
// unwatchPreferences();

Watch Options

Immediate Execution

By default the callback runs only when the value changes. Pass immediate: true to also run it once when the watcher is set up:

const state = reactive({ count: 0 });

// Only runs when count changes (default)
watch(() => state.count, (value) => {
  console.log(value);
});

// Also runs immediately with the current value
watch(() => state.count, (value) => {
  console.log(value);
}, { immediate: true });

Deep Watching

For deep watching of nested objects, use the deep option. This recursively tracks all nested properties using reactive dependency tracking:

const state = reactive({
  user: {
    profile: {
      name: 'John',
      email: 'john@example.com',
    },
  },
});

// Shallow watch (default) - only detects direct property changes
watch(() => state.user, (user) => {
  console.log('User object reference changed');
});

// Deep watch - detects nested property changes reactively
const unwatch = watch(() => state.user, (user) => {
  console.log('User or nested properties changed');
}, { deep: true });

// Changes to nested properties will trigger the callback
state.user.profile.name = 'Jane'; // Triggers deep watch

// Clean up when done
unwatch();

Note: Deep watching recursively accesses all nested properties to register reactive dependencies. For very large or deeply nested objects, prefer watching specific properties for better performance.

Custom Equality

The callback fires when the source returns a value that is not Object.is-equal to the previous one. A source that builds a new object on each run is never equal, so pass shallow to compare it one level deep:

import { reactive, shallow, watch } from '@quantajs/core';

const form = reactive({ errors: [], touched: [] });

// Re-runs whenever either list changes, but fires only when
// `valid` or `dirty` actually flips.
watch(
  () => ({ valid: form.errors.length === 0, dirty: form.touched.length > 0 }),
  (status) => updateSubmitButton(status),
  { equals: shallow },
);

The opposite also works: equals: () => false fires on every change the source tracks, including an in-place mutation of an object it returns by identity.

Cleanup

Watch returns a cleanup function that stops the watcher:

const state = reactive({ count: 0 });

const unwatch = watch(() => state.count, (value) => {
  console.log(value);
});

// Later, stop watching
unwatch();

// After unwatch, changes won't trigger the callback
state.count = 5; // No log output

Learn More

Spot something that needs improving?

Edit page