← Back to DevBytes

MobX from Beginner to Expert: A Learning Path

What is MobX?

MobX is a battle-tested, lightweight state management library for JavaScript applications. It makes state management transparent and predictable by applying functional reactive programming principles behind the scenes. The core idea is deceptively simple: anything that can be derived from the application state, should be derived automatically.

Unlike Redux's single-store, action-reducer pattern, MobX allows you to work with multiple observable stores and automatically tracks which parts of your state are used by which components. This eliminates the need for manual subscription management, selector memoization, or boilerplate-heavy action dispatching.

At its heart, MobX consists of four fundamental pillars:

MobX works beautifully with React (via mobx-react or mobx-react-lite), but it's framework-agnostic and can be used with Vue, Angular, or even vanilla JavaScript.

Why MobX Matters

πŸš€ Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

In modern frontend development, managing complex client-side state is one of the hardest problems. As applications grow, shared mutable state, asynchronous updates, and derived data create a tangled web of dependencies. MobX addresses these challenges through several key advantages:

The difference becomes stark when comparing a typical MobX store to an equivalent Redux setup. MobX lets you focus on business logic rather than plumbing.

Installation and Setup

Let's start by installing the necessary packages. For a React project, you'll typically want mobx and mobx-react-lite:

npm install mobx mobx-react-lite
# or
yarn add mobx mobx-react-lite

If you're using class components or need decorator support (older codebases), you may also install mobx-react with decorator support, but modern MobX (v6+) encourages the non-decorator API. Throughout this tutorial, we'll use the modern, non-decorator syntax.

For TypeScript users, no additional type packages are neededβ€”MobX ships with its own type definitions.

npm install mobx mobx-react-lite typescript @types/react

Level 1: Your First Observable Store

Creating Observable State

The most fundamental operation in MobX is making data observable. Let's build a simple counter store:

import { makeAutoObservable, makeObservable, observable, action, computed } from "mobx";

// Approach A: makeAutoObservable (recommended for beginners)
class CounterStore {
  count = 0;
  multiplier = 2;

  constructor() {
    makeAutoObservable(this);
  }

  increment() {
    this.count++;
  }

  decrement() {
    this.count--;
  }

  get doubledCount() {
    return this.count * this.multiplier;
  }

  reset() {
    this.count = 0;
  }
}

// Create a single instance (singleton store pattern)
const counterStore = new CounterStore();
export default counterStore;

makeAutoObservable automatically converts all properties to observable, all getters to computed, and all methods to action. This is the fastest way to get started and works perfectly for most use cases.

Connecting to React

To make a React component reactive to MobX state, wrap it with observer:

import React from "react";
import { observer } from "mobx-react-lite";
import counterStore from "./stores/CounterStore";

const Counter = observer(() => {
  return (
    <div>
      <h1>Count: {counterStore.count}</h1>
      <h2>Doubled: {counterStore.doubledCount}</h2>
      <button onClick={() => counterStore.increment()}>+</button>
      <button onClick={() => counterStore.decrement()}>-</button>
      <button onClick={() => counterStore.reset()}>Reset</button>
    </div>
  );
});

export default Counter;

The observer HOC (or wrapper function) converts your component into a reactive component that automatically re-renders when any observable value it reads during render is changed. Notice there's no useSelector, no connect, no mapStateToPropsβ€”you simply access properties directly.

How It Works Under the Hood

When the component renders, MobX tracks which observables are accessed. This creates a dependency graph. When counterStore.count changes, only components that read count (or a computed value derived from count) will re-render. Components reading unrelated observables remain untouched.

// Inside MobX's tracking system (simplified mental model):
// 1. Observer component renders β†’ tracking starts
// 2. counterStore.count is read β†’ dependency recorded
// 3. counterStore.doubledCount is read β†’ dependency recorded (transitively depends on count)
// 4. Tracking ends
// 5. Later: count changes β†’ notification fires β†’ component re-renders

Level 2: Understanding the Core API

Explicit Annotations with makeObservable

While makeAutoObservable is convenient, production code often requires explicit control. Use makeObservable to specify exactly which members are observable, actions, or computed:

import { makeObservable, observable, action, computed, flow } from "mobx";

class TodoStore {
  todos = [];
  filter = "all"; // 'all' | 'active' | 'completed'
  isLoading = false;

  constructor() {
    makeObservable(this, {
      todos: observable,
      filter: observable,
      isLoading: observable,
      // All getters become computed
      filteredTodos: computed,
      activeTodoCount: computed,
      completedTodoCount: computed,
      // All methods that mutate state become actions
      addTodo: action,
      toggleTodo: action,
      removeTodo: action,
      setFilter: action,
      // Async actions use flow
      fetchTodos: flow
    });
  }

  get filteredTodos() {
    switch (this.filter) {
      case "active":
        return this.todos.filter(t => !t.completed);
      case "completed":
        return this.todos.filter(t => t.completed);
      default:
        return this.todos;
    }
  }

  get activeTodoCount() {
    return this.todos.filter(t => !t.completed).length;
  }

  get completedTodoCount() {
    return this.todos.filter(t => t.completed).length;
  }

  addTodo(text) {
    this.todos.push({
      id: Date.now(),
      text,
      completed: false,
      createdAt: new Date()
    });
  }

  toggleTodo(id) {
    const todo = this.todos.find(t => t.id === id);
    if (todo) {
      todo.completed = !todo.completed;
    }
  }

  removeTodo(id) {
    this.todos = this.todos.filter(t => t.id !== id);
  }

  setFilter(filter) {
    this.filter = filter;
  }

  // Async action using generator function
  *fetchTodos() {
    this.isLoading = true;
    try {
      const response = yield fetch("https://api.example.com/todos");
      const data = yield response.json();
      this.todos = data.map(item => ({
        id: item.id,
        text: item.title,
        completed: item.completed,
        createdAt: new Date(item.createdAt)
      }));
    } catch (error) {
      console.error("Failed to fetch todos:", error);
    } finally {
      this.isLoading = false;
    }
  }
}

The Three Annotation Types Deep Dive

Observable – These are the tracked properties. MobX supports observable, observable.ref (shallow tracking), observable.shallow (one level deep), and observable.struct (structural comparison). Choose the right one for your data structure:

import { observable } from "mobx";

class ConfigStore {
  // Deep observable (default) - tracks every nested property
  settings = observable({
    theme: "light",
    notifications: { email: true, push: false }
  });

  // Reference only - only tracks if the reference changes
  externalData = observable.ref({ heavy: "payload" });

  // Shallow - tracks direct properties but not nested ones
  shallowMap = observable.shallow(new Map());

  // Struct - treats value as immutable, compares structurally
  point = observable.struct({ x: 0, y: 0 });
}

Action – Functions that modify state. By default in strict mode (enabled via configure({ enforceActions: "observed" })), state mutations outside actions throw errors. Actions batch mutations and notify observers only after completion:

import { action, runInAction } from "mobx";

class FormStore {
  name = "";
  email = "";
  errors = {};

  // Named action with explicit annotation
  updateField(field, value) {
    this[field] = value;
  }

  // Multiple mutations batched together
  submitForm() {
    // All mutations inside an action are batched
    // Observers are notified once after all mutations complete
    this.validate();
    if (Object.keys(this.errors).length === 0) {
      this.sendToServer();
    }
  }

  validate() {
    this.errors = {};
    if (!this.name) this.errors.name = "Name is required";
    if (!this.email.includes("@")) this.errors.email = "Invalid email";
  }

  // For wrapping non-action code (callbacks, promises)
  handleExternalUpdate(data) {
    runInAction(() => {
      this.name = data.name;
      this.email = data.email;
    });
  }
}

Computed – Values derived from state. They are lazy (only evaluated when accessed), cached (result reused until dependencies change), and pure (must not modify state):

import { computed, makeObservable, observable } from "mobx";

class ShoppingCart {
  items = [];

  constructor() {
    makeObservable(this, {
      items: observable,
      totalPrice: computed,
      itemCount: computed,
      hasDiscount: computed,
      discountAmount: computed,
      finalPrice: computed
    });
  }

  get totalPrice() {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  get itemCount() {
    return this.items.reduce((sum, item) => sum + item.quantity, 0);
  }

  get hasDiscount() {
    return this.totalPrice > 100;
  }

  get discountAmount() {
    return this.hasDiscount ? this.totalPrice * 0.1 : 0;
  }

  get finalPrice() {
    // Computed values can depend on other computed values
    // MobX builds the dependency chain automatically
    return this.totalPrice - this.discountAmount;
  }
}

// Usage: computed values behave like regular property access
const cart = new ShoppingCart();
cart.items.push({ name: "Book", price: 29.99, quantity: 2 });
console.log(cart.totalPrice); // 59.98
// Add more items to trigger discount
cart.items.push({ name: "Headphones", price: 89.99, quantity: 1 });
console.log(cart.finalPrice); // 107.972 (with discount applied)

Level 3: Reactions and Side Effects

While computed values derive new values from state, reactions produce side effects. MobX provides three main reaction primitives:

autorun

autorun runs a function immediately and then re-runs it whenever any observable it reads changes. Perfect for logging, debugging, or imperative DOM updates:

import { autorun, observable } from "mobx";

const temperature = observable.box(25); // observable.box for primitive values

const disposer = autorun(() => {
  console.log(`Current temperature: ${temperature.get()}Β°C`);
  // This runs immediately and then whenever temperature changes
});

temperature.set(30); // Logs: Current temperature: 30Β°C
temperature.set(18); // Logs: Current temperature: 18Β°C

// Always dispose when no longer needed
disposer();

reaction

reaction separates the tracking function from the effect function. The first argument tracks dependencies (like a computed), and the second runs only when those dependencies change. This gives you precise control:

import { reaction, makeAutoObservable } from "mobx";

class DocumentStore {
  title = "Untitled";
  content = "";
  lastSaved = null;

  constructor() {
    makeAutoObservable(this);
  }
}

const docStore = new DocumentStore();

// Track specific observables, run effect only when they change
const disposer = reaction(
  // Tracking function - returns the data to watch
  () => ({
    title: docStore.title,
    content: docStore.content
  }),
  // Effect function - receives the current and previous tracked values
  ({ title, content }, prev) => {
    console.log(`Document changed from "${prev.title}" to "${title}"`);
    // Trigger auto-save, update preview, etc.
    scheduleAutoSave(title, content);
  },
  {
    // Optional: don't run immediately, only on changes
    fireImmediately: false,
    // Optional: delay execution (debounce)
    delay: 1000
  }
);

docStore.title = "My Document"; // Effect runs after 1 second (debounced)

when

when runs an effect once when a condition becomes true and then automatically disposes itself:

import { when, makeAutoObservable } from "mobx";

class GameStore {
  score = 0;
  level = 1;

  constructor() {
    makeAutoObservable(this);
  }
}

const gameStore = new GameStore();

// Execute side effect once when condition is met
when(
  () => gameStore.score >= 1000 && gameStore.level === 1,
  () => {
    console.log("Level up! Reached 1000 points.");
    gameStore.level = 2;
    // This reaction disposes itself after running once
  }
);

// Simulate gameplay
for (let i = 0; i < 50; i++) {
  gameStore.score += 20;
}
// At some point score reaches 1000 β†’ level up triggers

Custom Reactions with Observer

In React, the observer wrapper itself is a reactionβ€”it's essentially an autorun that re-renders the component when dependencies change. You rarely need explicit reactions in React apps because components serve this purpose:

import { observer } from "mobx-react-lite";
import { useStore } from "./StoreContext";

const NotificationBadge = observer(() => {
  const store = useStore();

  // Component automatically re-renders when unreadCount changes
  return (
    <div className="badge">
      {store.unreadCount > 0 && <span>{store.unreadCount}</span>}
    </div>
  );
});

// Equivalent explicit reaction (not needed in React, but shows the pattern):
// const disposer = autorun(() => {
//   document.getElementById("badge").textContent = store.unreadCount;
// });

Level 4: Advanced Store Architecture

Store Composition and Context

Real applications need multiple stores that can reference each other. MobX excels at this through straightforward dependency injection:

import { makeAutoObservable } from "mobx";
import { createContext, useContext } from "react";

// UserStore - manages authentication and user data
class UserStore {
  user = null;
  token = null;

  constructor(rootStore) {
    this.rootStore = rootStore; // Reference to parent store
    makeAutoObservable(this);
  }

  get isLoggedIn() {
    return !!this.token;
  }

  login(email, password) {
    // After successful login
    this.token = "auth-token-123";
    this.user = { email, name: "John" };
    // Cross-store communication via rootStore
    this.rootStore.cartStore.loadUserCart();
  }

  logout() {
    this.token = null;
    this.user = null;
    this.rootStore.resetAll();
  }
}

// CartStore - manages shopping cart
class CartStore {
  items = [];

  constructor(rootStore) {
    this.rootStore = rootStore;
    makeAutoObservable(this);
  }

  get itemCount() {
    return this.items.reduce((sum, item) => sum + item.quantity, 0);
  }

  addItem(product) {
    const existing = this.items.find(item => item.id === product.id);
    if (existing) {
      existing.quantity++;
    } else {
      this.items.push({ ...product, quantity: 1 });
    }
  }

  loadUserCart() {
    // Fetch cart from server using user identity from UserStore
    // this.rootStore.userStore.user.id
  }
}

// UIStore - manages UI state like modals, sidebar, notifications
class UIStore {
  sidebarOpen = false;
  activeModal = null;
  notifications = [];

  constructor(rootStore) {
    this.rootStore = rootStore;
    makeAutoObservable(this);
  }

  toggleSidebar() {
    this.sidebarOpen = !this.sidebarOpen;
  }

  showNotification(message, type = "info") {
    this.notifications.push({ id: Date.now(), message, type });
    setTimeout(() => this.dismissNotification(this.notifications[0]?.id), 5000);
  }

  dismissNotification(id) {
    this.notifications = this.notifications.filter(n => n.id !== id);
  }
}

// RootStore - aggregates all stores
class RootStore {
  constructor() {
    this.userStore = new UserStore(this);
    this.cartStore = new CartStore(this);
    this.uiStore = new UIStore(this);
    makeAutoObservable(this);
  }

  resetAll() {
    // Reset logic coordinated across stores
    this.cartStore.items = [];
  }
}

// React Context for dependency injection
const StoreContext = createContext(null);

export const StoreProvider = ({ children }) => {
  const rootStore = new RootStore();
  return (
    <StoreContext.Provider value={rootStore}>
      {children}
    </StoreContext.Provider>
  );
};

export const useStore = () => {
  const store = useContext(StoreContext);
  if (!store) throw new Error("useStore must be used within StoreProvider");
  return store;
};

This pattern scales beautifully. Stores can communicate through the root store reference, and React components access any store via a single context.

Async Actions with flow

MobX provides flow for handling asynchronous operations using generator functions. This is the recommended approach for complex async flows:

import { flow, makeAutoObservable, runInAction } from "mobx";

class SearchStore {
  query = "";
  results = [];
  isLoading = false;
  error = null;
  page = 1;
  totalPages = 1;

  constructor() {
    makeAutoObservable(this, {
      search: flow,
      loadNextPage: flow
    });
  }

  // Generator-based async action
  *search(query) {
    this.query = query;
    this.isLoading = true;
    this.error = null;
    this.results = [];
    this.page = 1;

    try {
      const response = yield fetch(
        `https://api.example.com/search?q=${encodeURIComponent(query)}&page=1`
      );
      const data = yield response.json();
      this.results = data.items;
      this.totalPages = data.totalPages;
    } catch (err) {
      this.error = err.message;
    } finally {
      this.isLoading = false;
    }
  }

  *loadNextPage() {
    if (this.page >= this.totalPages || this.isLoading) return;

    this.isLoading = true;
    this.page++;

    try {
      const response = yield fetch(
        `https://api.example.com/search?q=${encodeURIComponent(this.query)}&page=${this.page}`
      );
      const data = yield response.json();
      // Append new results
      this.results = [...this.results, ...data.items];
    } catch (err) {
      this.page--; // Rollback page increment on error
      this.error = err.message;
    } finally {
      this.isLoading = false;
    }
  }
}

// Alternative: async/await with runInAction (if you prefer promises over generators)
class ModernSearchStore {
  query = "";
  results = [];
  isLoading = false;

  constructor() {
    makeAutoObservable(this);
  }

  async searchModern(query) {
    this.query = query;
    this.isLoading = true;

    try {
      const response = await fetch(`https://api.example.com/search?q=${query}`);
      const data = await response.json();

      // Must wrap mutations in runInAction for async code
      runInAction(() => {
        this.results = data.items;
      });
    } catch (err) {
      runInAction(() => {
        this.error = err.message;
      });
    } finally {
      runInAction(() => {
        this.isLoading = false;
      });
    }
  }
}

The key rule: in async operations, always wrap state mutations in runInAction or use flow. Await breaks the action context, so the code after await is no longer inside an action.

Level 5: Performance Optimization and Best Practices

Granular Observers with React

One of MobX's greatest strengths is granular re-rendering. To maximize this, structure your components to read the minimal set of observables:

import { observer } from "mobx-react-lite";
import { useStore } from "./StoreContext";

// BAD: Large component reads many unrelated observables
const BadDashboard = observer(() => {
  const store = useStore();
  // Reading user, cart, UI, notifications all in one component
  // Any change to any of these triggers re-render of entire dashboard
  return (
    <div>
      <UserPanel user={store.userStore.user} />
      <CartPreview items={store.cartStore.items} />
      <NotificationList notifications={store.uiStore.notifications} />
    </div>
  );
});

// GOOD: Split into focused components that each read minimal state
const Dashboard = () => {
  return (
    <div>
      <UserPanel />
      <CartPreview />
      <NotificationList />
    </div>
  );
};

const UserPanel = observer(() => {
  const store = useStore();
  // Only re-renders when user data changes
  const { user } = store.userStore;
  return <div>{user?.name}</div>;
});

const CartPreview = observer(() => {
  const store = useStore();
  // Only re-renders when itemCount changes
  return <div>Items: {store.cartStore.itemCount}</div>;
});

const NotificationList = observer(() => {
  const store = useStore();
  // Only re-renders when notifications array changes
  return (
    <div>
      {store.uiStore.notifications.map(n => (
        <div key={n.id}>{n.message}</div>
      ))}
    </div>
  );
});

Computed Values vs. Reactions

Use computed values whenever you need a derived value that will be consumed by other code. Use reactions only for imperative side effects (logging, DOM manipulation, network requests triggered by state changes):

// GOOD: Use computed for derived data consumed by components
class OrderStore {
  lineItems = [];

  constructor() {
    makeAutoObservable(this);
  }

  get subtotal() {
    return this.lineItems.reduce((sum, item) => sum + item.price * item.qty, 0);
  }

  get tax() {
    return this.subtotal * 0.08; // Computed depends on another computed
  }

  get total() {
    return this.subtotal + this.tax;
  }

  get summary() {
    // Computed values can create complex derived structures
    return {
      itemCount: this.lineItems.length,
      subtotal: this.subtotal,
      tax: this.tax,
      total: this.total,
      timestamp: new Date()
    };
  }
}

// Use reaction for side effects that don't produce values
const disposer = reaction(
  () => orderStore.total,
  (total) => {
    // Side effect: update analytics, save to localStorage, etc.
    analytics.track("order_total_changed", { total });
    localStorage.setItem("cart_total", total);
  }
);

Action Batching and Transactions

Actions automatically batch updates. Multiple mutations within a single action result in a single notification cycle:

import { action, transaction, runInAction } from "mobx";

class GameEngine {
  playerPosition = { x: 0, y: 0 };
  playerHealth = 100;
  enemies = [];
  score = 0;

  constructor() {
    makeAutoObservable(this);
  }

  // Single action - all mutations batched
  takeDamage(amount) {
    // All three mutations complete before observers are notified
    this.playerHealth -= amount;
    this.updatePlayerAnimation("hurt");
    this.checkGameOver();
  }

  // Use transaction for explicit batching across actions
  processTurn() {
    transaction(() => {
      this.moveEnemies();
      this.resolveCombat();
      this.spawnItems();
      // Observers notified once after transaction completes
    });
  }

  // Non-action code (event handlers, timeouts) use runInAction
  handleKeyPress(key) {
    // This might be called from a non-MobX context
    runInAction(() => {
      if (key === "ArrowUp") this.playerPosition.y -= 1;
      if (key === "ArrowDown") this.playerPosition.y += 1;
    });
  }
}

Configuring MobX for Production

MobX provides global configuration options that affect behavior:

import { configure } from "mobx";

// Recommended production configuration
configure({
  // Enforce that state mutations only happen inside actions
  enforceActions: "observed",

  // Use isolates for computed values (safer, prevents cross-observer leaks)
  computedRequiresReaction: false,

  // Prevent observable values from being created outside makeObservable
  reactionRequiresObservable: false,

  // Disable MobX warnings in production
  observableRequiresReaction: false,

  // Use Proxy-based reactivity (modern, performant)
  useProxies: "always"
});

Level 6: MobX with TypeScript

MobX and TypeScript work exceptionally well together. Here's how to leverage TypeScript effectively:

import { makeAutoObservable, makeObservable, observable, action, computed, runInAction, flow } from "mobx";

// Define interfaces for your data
interface Todo {
  id: string;
  text: string;
  completed: boolean;
  createdAt: Date;
  tags: string[];
}

interface TodoFilter {
  status: "all" | "active" | "completed";
  searchQuery: string;
  tagFilter: string | null;
}

class TypedTodoStore {
  todos: Todo[] = [];
  filter: TodoFilter = {
    status: "all",
    searchQuery: "",
    tagFilter: null
  };
  isLoading = false;
  error: string | null = null;

  constructor() {
    makeAutoObservable(this);
    // TypeScript automatically infers types from the class properties
    // No need for explicit generic parameters in most cases
  }

  // Computed with explicit return type (optional but good practice)
  get filteredTodos(): Todo[] {
    let result = this.todos;

    // Filter by status
    if (this.filter.status === "active") {
      result = result.filter(t => !t.completed);
    } else if (this.filter.status === "completed") {
      result = result.filter(t => t.completed);
    }

    // Filter by search
    if (this.filter.searchQuery) {
      const q = this.filter.searchQuery.toLowerCase();
      result = result.filter(t => t.text.toLowerCase().includes(q));
    }

    // Filter by tag
    if (this.filter.tagFilter) {
      result = result.filter(t => t.tags.includes(this.filter.tagFilter!));
    }

    return result;
  }

  get activeCount(): number {
    return this.todos.filter(t => !t.completed).length;
  }

  get completedCount(): number {
    return this.todos.filter(t => t.completed).length;
  }

  // Action with typed parameters
  addTodo(text: string, tags: string[] = []): void {
    this.todos.push({
      id: crypto.randomUUID(),
      text,
      completed: false,
      createdAt: new Date(),
      tags
    });
  }

  toggleTodo(id: string): void {
    const todo = this.todos.find(t => t.id === id);
    if (todo) {
      todo.completed = !todo.completed;
    }
  }

  updateFilter(partial: Partial): void {
    this.filter = { ...this.filter, ...partial };
  }

  // Typed async action using generator
  *fetchTodos(): Generator {
    this.isLoading = true;
    this.error = null;

    try {
      const response: Response = yield fetch("/api/todos");
      const data: Todo[] = yield response.json();
      this.todos = data;
    } catch (err) {
      this.error = err instanceof Error ? err.message : "Unknown error";
    } finally {
      this.isLoading = false;
    }
  }
}

// Typed React component using the store
import { observer } from "mobx-react-lite";

interface TodoItemProps {
  todo: Todo;
  onToggle: (id: string) => void;
}

const TodoItem: React.FC = observer(({ todo, onToggle }) => {
  return (
    <div className={`todo-item ${todo.completed ? "completed" : ""}`}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      <span>{todo.text}</span>
      {todo.tags.map(tag => <span key={tag} className="tag">{tag}</span>)}
    </div>
  );
});

TypeScript with MobX requires minimal ceremony. The class properties serve as both TypeScript types and MobX annotations. Computed getters have their return types inferred, and action methods benefit from parameter type checking.

Level 7: Testing MobX Stores

MobX stores are plain JavaScript objects, making them straightforward to test. Here's a comprehensive testing approach:

import { autorun, reaction, when } from "mobx";

// Test file for TodoStore
describe("TodoStore", () => {
  let store;

  beforeEach(() => {
    store = new TypedTodoStore();
  });

  test("addTodo should add a new todo item", () => {
    store.addTodo("Buy groceries", ["shopping"]);

    expect(store.todos).toHaveLength(1);
    expect(store.todos[0].text).toBe("Buy groceries");
    expect(store.todos[0].completed).toBe(false);
    expect(store.todos[0].tags).toEqual(["shopping"]);
  });

  test("toggleTodo should flip completion status", () => {
    store.addTodo("Test todo");
    const id = store.todos[0].id;

    store.toggleTodo(id);
    expect(store.todos[0].completed).toBe(true);

    store.toggleTodo(id);
    expect(store.todos[0].completed).toBe(false);
  });

  test("filteredTodos should filter by status correctly", () => {
    store.addTodo("Todo 1");
    store.addTodo("Todo 2");
    store.addTodo("Todo 3");

    // Complete the second todo
    store.toggleTodo(store.todos[1].id);

    // Test "active" filter
    store.updateFilter({ status: "active" });
    expect(store.filteredTodos).toHaveLength(2);

    // Test "completed" filter
    store.updateFilter

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles