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:
Single Source of Truth - All state in one place
Predictable - Easy to know what data exists
Debuggable - Just log appState to see everything
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 ();
}
Set Loading
Update state to show loading indicator
Clear Errors
Reset any previous error messages
Update UI
Render loading spinner
Fetch Data
Make API call (might throw error)
Update State
Save data and set success status
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:
Approach Pros Cons Separate Simpler, cart independent Two state sources Combined Single source of truth More 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 ();
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