Skip to main content

State Management Overview

State management is about controlling and organizing all the data your application needs to remember. Instead of scattering variables throughout your code, we centralize them in a single source of truth.

What You’ll Learn

  • Application state structure
  • State updates and reactivity
  • Unidirectional data flow
  • State-driven UI updates
  • Debugging state changes

What is “State”?

Think of state as your application’s memory. It’s all the data that can change over time:
  • What products are loaded?
  • Is data currently loading?
  • Did an error occur?
  • What’s in the shopping cart?
  • Which user is logged in?
If it can change, it’s probably state. If it’s constant (like API URLs), it’s configuration.

Application State Structure

Our app uses a centralized state object:
interface AppState {
  status: LoadingState;     // Current loading status
  products: Product[];      // Array of products from API
  error: string | null;     // Error message (or null if no error)
}

let appState: AppState = {
  status: LoadingState.Idle,
  products: [],
  error: null
};

Why This Structure?

Benefits:
  1. Single Source of Truth - All state in one place
  2. Predictable - Easy to know what data exists
  3. Debuggable - Just log appState to see everything
  4. Testable - Pass state to functions, assert results
vs Scattered State:
// Bad: State everywhere
let products = [];
let isLoading = false;
let errorMessage = null;
let currentUser = null;
let theme = 'light';
// Where are these used? Who updates them? Hard to track!

Loading States with Enum

We use an enum to represent all possible loading states:
enum LoadingState {
  Idle = "IDLE",         // Initial state, nothing happened yet
  Loading = "LOADING",   // Currently fetching data
  Success = "SUCCESS",   // Data fetched successfully
  Error = "ERROR"        // Fetch failed
}

State Transitions

  IDLE

  (User clicks "Load Products")

 LOADING

  ┌─────────────┐
  │   Fetch API   │
  └─────┬───────┘

  ┌───┼───┐
  │       │   │
  │       │   │
Success  Error
  │       │
  v       v
Show    Show
Data    Error

Why Not Just Boolean?

Bad approach:
let isLoading = false;
let hasError = false;
Problems:
  • Can both be true (impossible state)
  • Can both be false but have data (confusing)
  • Hard to add new states
Good approach with enum:
status: LoadingState.Loading
Benefits:
  • Only one state at a time
  • Compiler ensures you handle all cases
  • Easy to add new states later

State Update Functions

Loading Products

async function loadProducts(): Promise<void> {
  // 1. Transition to Loading state
  appState.status = LoadingState.Loading;
  appState.error = null; // Clear previous errors
  updateUI(); // Trigger re-render
  
  try {
    // 2. Fetch data
    const products = await fetchProducts(20);
    
    // 3. Transition to Success state
    appState.status = LoadingState.Success;
    appState.products = products;
    
  } catch (error) {
    // 4. Transition to Error state
    appState.error = error instanceof Error ? error.message : "Error desconocido";
    appState.status = LoadingState.Error;
    console.error("Error al cargar productos:", error);
  }
  
  // 5. Trigger re-render (success or error)
  updateUI();
}
1

Set Loading

Update state to show loading indicator
2

Clear Errors

Reset any previous error messages
3

Update UI

Render loading spinner
4

Fetch Data

Make API call (might throw error)
5

Update State

Save data and set success status
6

Update UI Again

Render products or error message

UI Updates from State

The updateUI() function reads the state and updates the DOM accordingly:
function updateUI(): void {
  const grid = getElement<HTMLDivElement>("#products-grid");
  const loading = getElement<HTMLDivElement>("#products-loading");
  const error = getElement<HTMLDivElement>("#products-error");
  const loadBtn = getElement<HTMLButtonElement>("#load-products-btn");
  
  // Hide all states by default
  loading.hidden = true;
  error.hidden = true;
  
  // Switch based on current state
  switch (appState.status) {
    case LoadingState.Idle:
      grid.innerHTML = `<p class="products__empty-state">Haz clic en "Cargar Productos"</p>`;
      loadBtn.disabled = false;
      loadBtn.textContent = "Cargar Productos";
      break;
      
    case LoadingState.Loading:
      grid.innerHTML = '';
      loading.hidden = false; // Show spinner
      loadBtn.disabled = true; // Disable button
      loadBtn.textContent = "Cargando...";
      break;
      
    case LoadingState.Success:
      renderProducts(); // Render all products
      loadBtn.disabled = false;
      loadBtn.textContent = "Recargar Productos";
      break;
      
    case LoadingState.Error:
      grid.innerHTML = '';
      error.hidden = false; // Show error
      const errorMessage = error.querySelector(".products__error-message");
      if (errorMessage) {
        errorMessage.textContent = appState.error || "Error desconocido";
      }
      loadBtn.disabled = false;
      loadBtn.textContent = "Reintentar";
      break;
  }
}

Key Principle: State → UI

State Changes

updateUI() called

Read appState

Update DOM to match state
The UI is always a reflection of the state. Never the other way around.
Don’t update state based on UI. Always update state first, then update UI to match.

Cart State Management

The shopping cart uses a separate Map for its state:
const cart: Map<number, number> = new Map();
// Key: Product ID, Value: Quantity

Why Separate from appState?

You could include cart in appState:
interface AppState {
  status: LoadingState;
  products: Product[];
  error: string | null;
  cart: Map<number, number>; // Add cart here
}
Trade-offs:
ApproachProsCons
SeparateSimpler, cart independentTwo state sources
CombinedSingle source of truthMore complex structure
For this tutorial, we keep it separate for simplicity.

Cart Operations

// Add to cart
function addToCart(productId: number): void {
  const currentQty = cart.get(productId) || 0;
  cart.set(productId, currentQty + 1);
  updateCartCount(); // Update UI
}

// Update cart badge
function updateCartCount(): void {
  const total = Array.from(cart.values())
    .reduce((sum, qty) => sum + qty, 0);
  
  const badge = document.querySelector('[data-cart-count]');
  if (badge) {
    badge.setAttribute('data-cart-count', total.toString());
  }
}

Unidirectional Data Flow

Our app follows a unidirectional (one-way) data flow:
   User Action (click button)

   Update State
   (appState.status = Loading)

   Render UI
   (updateUI())

   User sees changes

   User Action...
   (cycle repeats)
vs Bidirectional (problematic):
State ↔ UI
  ↑      ↓
  └──────┘
  Chaos!
With bidirectional flow, it’s hard to track where changes come from.

Debugging State

Console Logging

function loadProducts(): Promise<void> {
  console.log('Before:', appState);
  
  appState.status = LoadingState.Loading;
  console.log('After state update:', appState);
  
  // ... rest of function
}

State Inspector

Create a debug panel:
function createDebugPanel(): void {
  const panel = document.createElement('div');
  panel.style.cssText = `
    position: fixed;
    bottom: 10px;
    left: 10px;
    padding: 10px;
    background: rgba(0, 0, 0, 0.8);
    color: white;
    font-family: monospace;
    font-size: 12px;
    border-radius: 4px;
    z-index: 9999;
  `;
  
  function update() {
    panel.innerHTML = `
      <strong>App State:</strong><br>
      Status: ${appState.status}<br>
      Products: ${appState.products.length}<br>
      Error: ${appState.error || 'None'}<br>
      Cart: ${cart.size} items
    `;
  }
  
  update();
  setInterval(update, 1000); // Update every second
  
  document.body.appendChild(panel);
}

// Call on init
createDebugPanel();

React DevTools Pattern

Inspire by React, you could create a state history:
const stateHistory: AppState[] = [];

function setState(updates: Partial<AppState>): void {
  // Save current state to history
  stateHistory.push({ ...appState });
  
  // Apply updates
  Object.assign(appState, updates);
  
  // Update UI
  updateUI();
  
  // Log change
  console.log('State updated:', updates);
}

// Use it
setState({ status: LoadingState.Loading });
setState({ status: LoadingState.Success, products: data });

// View history
console.log(stateHistory);

State Management Patterns

Pattern 1: Simple Object (This Tutorial)

let appState = {
  status: LoadingState.Idle,
  products: [],
  error: null
};

// Update directly
appState.status = LoadingState.Loading;
updateUI();
Pros: Simple, easy to understand Cons: No change tracking, manual UI updates

Pattern 2: Getter/Setter

class AppState {
  private _status: LoadingState = LoadingState.Idle;
  
  get status(): LoadingState {
    return this._status;
  }
  
  set status(value: LoadingState) {
    this._status = value;
    this.notify(); // Auto-update UI
  }
  
  private notify(): void {
    updateUI();
  }
}

const appState = new AppState();
appState.status = LoadingState.Loading; // Automatically calls updateUI()
Pros: Automatic UI updates Cons: More boilerplate

Pattern 3: Redux-Style

type Action = 
  | { type: 'LOAD_START' }
  | { type: 'LOAD_SUCCESS', payload: Product[] }
  | { type: 'LOAD_ERROR', payload: string };

function reducer(state: AppState, action: Action): AppState {
  switch (action.type) {
    case 'LOAD_START':
      return { ...state, status: LoadingState.Loading };
    case 'LOAD_SUCCESS':
      return { ...state, status: LoadingState.Success, products: action.payload };
    case 'LOAD_ERROR':
      return { ...state, status: LoadingState.Error, error: action.payload };
    default:
      return state;
  }
}

// Usage
appState = reducer(appState, { type: 'LOAD_START' });
appState = reducer(appState, { type: 'LOAD_SUCCESS', payload: products });
Pros: Predictable, testable, time-travel debugging Cons: Verbose, learning curve

Pattern 4: Signals/Observables

import { signal } from '@preact/signals';

const status = signal(LoadingState.Idle);
const products = signal<Product[]>([]);

// Auto-updates when changed
effect(() => {
  console.log('Status changed:', status.value);
  updateUI();
});

// Update
status.value = LoadingState.Loading;
Pros: Automatic reactivity, minimal code Cons: Requires library, new concepts

State Persistence

Save state to localStorage:
function saveState(): void {
  const stateToSave = {
    products: appState.products,
    cart: Array.from(cart.entries())
  };
  localStorage.setItem('appState', JSON.stringify(stateToSave));
}

function loadState(): void {
  const saved = localStorage.getItem('appState');
  if (!saved) return;
  
  try {
    const parsed = JSON.parse(saved);
    
    if (parsed.products) {
      appState.products = parsed.products;
      appState.status = LoadingState.Success;
    }
    
    if (parsed.cart) {
      parsed.cart.forEach(([id, qty]) => cart.set(id, qty));
      updateCartCount();
    }
    
    updateUI();
  } catch (error) {
    console.error('Failed to load state:', error);
  }
}

// Call on app init
loadState();

// Save on changes
window.addEventListener('beforeunload', saveState);

State Testing

// Test state transitions
function testLoadingFlow() {
  // Initial state
  assert(appState.status === LoadingState.Idle);
  assert(appState.products.length === 0);
  
  // Start loading
  appState.status = LoadingState.Loading;
  assert(appState.status === LoadingState.Loading);
  
  // Success
  const mockProducts: Product[] = [/* mock data */];
  appState.status = LoadingState.Success;
  appState.products = mockProducts;
  assert(appState.products.length === mockProducts.length);
  
  console.log('All tests passed!');
}

Complete Code Reference

State Interface: /workspace/source/mi-tutorial/src/main.ts:219-224 Initial State: /workspace/source/mi-tutorial/src/main.ts:228-232 Enum Definition: /workspace/source/mi-tutorial/src/main.ts:169-174 Update UI Function: /workspace/source/mi-tutorial/src/main.ts:536-589 Load Products: /workspace/source/mi-tutorial/src/main.ts:931-960

Best Practices

Single Source of Truth - All state in one place
Immutable Updates - Create new state instead of mutating
State First - Update state, then UI follows
Type Safety - Use TypeScript interfaces for state shape
Enum for States - Better than strings or booleans
Centralized Updates - Functions that update state and UI together
Log Changes - Console log for debugging

Common Mistakes

Don’t update state in multiple places
// Bad: State scattered
function loadProducts() {
  appState.status = LoadingState.Loading;
}

function onError() {
  appState.status = LoadingState.Error; // Another place!
}
Instead, centralize in one function.
Don’t forget to update UI
// Bad: State changed but UI not updated
appState.products = newProducts;
// UI still shows old products!

// Good:
appState.products = newProducts;
updateUI(); // Now UI matches state
Don’t mutate state directly in complex scenarios
// Bad: Mutating nested objects
appState.products[0].price = 999;
// Hard to track changes

// Good: Create new state
appState.products = appState.products.map((p, i) => 
  i === 0 ? { ...p, price: 999 } : p
);

Next Steps

Congratulations! You’ve completed the project tutorial. Here are some ways to extend your learning:

Add Features

  • Product filtering
  • Search functionality
  • Pagination
  • User authentication

Improve State

  • Implement Redux
  • Add state persistence
  • Use React/Vue for reactivity

Enhance UX

  • Add animations
  • Skeleton loaders
  • Optimistic updates
  • Offline support

Testing

  • Unit tests for state
  • Integration tests
  • E2E tests with Playwright

Build docs developers (and LLMs) love