Skip to content

State Management Best Practices for PrimeThink Live Apps

Why Avoid localStorage?

localStorage is browser-specific and creates problems:

  1. Not shared across windows - Opening the same chat in multiple tabs shows different states
  2. Not shared across devices - User can't continue on mobile what they started on desktop
  3. Not shared across users - Collaborative apps can't share state
  4. Lost on browser clear - Users lose progress if they clear browser data
  5. No server-side access - AI can't read or modify the state
  6. Limited to 5-10MB - Can't store large amounts of data

Use Chat Database Instead

PrimeThink Live Apps have access to a chat-scoped database via the pt API. This is the recommended way to persist state.

Benefits of Chat Database

  • Shared across all windows and devices - True multi-window support
  • Persistent and reliable - Survives browser restarts and data clearing
  • AI-accessible - AI can read and modify stored data
  • Collaborative - Multiple users can share the same state
  • Unlimited storage - No practical size limits
  • Queryable - Use filters to find specific data

Replace localStorage with a single database entity for UI state.

Bad: Using localStorage

// DON'T DO THIS
function saveState() {
  localStorage.setItem('app_state', JSON.stringify(AppState));
}

function loadState() {
  const saved = localStorage.getItem('app_state');
  if (saved) {
    AppState = JSON.parse(saved);
  }
}

Good: Using Chat Database

// DO THIS INSTEAD
const APP_STATE_ENTITY = 'app_ui_state';
let appStateId = null;

// Save state to chat database
async function saveState() {
  try {
    const stateData = { ...AppState };

    if (appStateId) {
      // Update existing state
      await pt.edit(appStateId, stateData, true);
    } else {
      // Create new state entity
      const saved = await pt.add(APP_STATE_ENTITY, stateData);
      appStateId = saved.id;
    }
  } catch (error) {
    console.error('Failed to save state:', error);
  }
}

// Load state from chat database
async function loadState() {
  try {
    const result = await pt.list({
      entityNames: [APP_STATE_ENTITY],
      limit: 1
    });

    const entities = Array.isArray(result) ? result : (result.entities || []);
    if (entities.length > 0) {
      const saved = entities[0];
      appStateId = saved.id;
      Object.assign(AppState, saved.data);
      console.log('State loaded from database');
    }
  } catch (error) {
    console.error('Failed to load state:', error);
  }
}

// Initialize app
async function initializeApp() {
  await loadState(); // Load state before rendering
  renderScreen();
}

Key Points

  1. Use a dedicated entity type - app_ui_state or similar
  2. Track the entity ID - Store appStateId to update the same entity
  3. Use merge mode - pt.edit(id, data, true) preserves fields you don't update
  4. Await on init - Always await loadState() before rendering
  5. Handle errors gracefully - Don't crash if database is unavailable

Pattern 2: Structured Data Storage

For complex apps, store different types of data in separate entities.

Example: Task Management App

// Store different data types separately
async function saveTask(taskData) {
  await pt.add('task', taskData);
}

async function saveUserPreferences(prefs) {
  if (userPrefsId) {
    await pt.edit(userPrefsId, prefs, true);
  } else {
    const saved = await pt.add('user_preferences', prefs);
    userPrefsId = saved.id;
  }
}

async function saveCurrentView(viewState) {
  if (viewStateId) {
    await pt.edit(viewStateId, viewState, true);
  } else {
    const saved = await pt.add('view_state', viewState);
    viewStateId = saved.id;
  }
}

// Load on init
async function initializeApp() {
  // Load preferences
  const prefsResult = await pt.list({
    entityNames: ['user_preferences'],
    limit: 1
  });
  if (prefsResult.entities?.length > 0) {
    userPrefsId = prefsResult.entities[0].id;
    userPreferences = prefsResult.entities[0].data;
  }

  // Load view state
  const viewResult = await pt.list({
    entityNames: ['view_state'],
    limit: 1
  });
  if (viewResult.entities?.length > 0) {
    viewStateId = viewResult.entities[0].id;
    viewState = viewResult.entities[0].data;
  }

  // Load tasks
  const tasksResult = await pt.list({
    entityNames: ['task'],
    limit: 100
  });
  tasks = tasksResult.entities || [];

  renderApp();
}

Pattern 3: Session-Based State

For apps with multiple sessions (like the 11+ writing app), use status fields to track progress.

// Create a new session
async function startNewSession() {
  const session = await pt.add('writing_session', {
    started_at: new Date().toISOString(),
    status: 'in_progress',
    prompt: selectedPrompt,
    timer_duration: 30
  });

  currentSessionId = session.id;
  return session;
}

// Update session as user progresses
async function updateSession(updates) {
  if (currentSessionId) {
    await pt.edit(currentSessionId, updates, true);
  }
}

// Mark session complete
async function completeSession(results) {
  await pt.edit(currentSessionId, {
    status: 'completed',
    completed_at: new Date().toISOString(),
    results: results
  }, true);
}

// Load in-progress session on init
async function loadInProgressSession() {
  const result = await pt.list({
    entityNames: ['writing_session'],
    filters: { status: 'in_progress' },
    limit: 1
  });

  if (result.entities?.length > 0) {
    currentSessionId = result.entities[0].id;
    return result.entities[0];
  }
  return null;
}

What NOT to Store in Database

Some data should remain in memory only:

Don't Store These

// Runtime-only data
let timerInterval = null;        // Interval handles
let currentOperation = null;     // Cancellation tokens
let selectedFiles = [];          // File objects (can't serialize)
let domReferences = {};          // DOM element references
let callbacks = [];              // Function references
let webSockets = null;           // Connection objects

Why?

  • Can't be serialized - File objects, functions, DOM elements
  • Temporary/transient - Interval handles, connection objects
  • Recreated on load - Should be rebuilt from persisted data

Performance Considerations

Debounce Frequent Updates

Don't save on every keystroke - debounce updates:

let saveTimeout = null;

function debouncedSave() {
  clearTimeout(saveTimeout);
  saveTimeout = setTimeout(() => {
    saveState();
  }, 1000); // Save 1 second after last change
}

// Use in input handlers
function handleInputChange(value) {
  AppState.inputValue = value;
  debouncedSave(); // Debounced save
}

Batch Updates

Group related changes into a single save:

// Bad: Multiple saves
async function updateMultipleFields() {
  AppState.field1 = 'value1';
  await saveState();
  AppState.field2 = 'value2';
  await saveState();
  AppState.field3 = 'value3';
  await saveState();
}

// Good: Single save
async function updateMultipleFields() {
  AppState.field1 = 'value1';
  AppState.field2 = 'value2';
  AppState.field3 = 'value3';
  await saveState(); // Save once
}

Use Optimistic Updates

Update UI immediately, save in background:

async function toggleTask(taskId) {
  // Update UI immediately
  const task = tasks.find(t => t.id === taskId);
  task.completed = !task.completed;
  renderTasks();

  // Save in background (don't await)
  pt.edit(taskId, { completed: task.completed }, true)
    .catch(error => {
      console.error('Failed to save:', error);
      // Optionally revert UI on error
      task.completed = !task.completed;
      renderTasks();
    });
}

Migration from localStorage

If you have an existing app using localStorage, migrate gradually:

Step 1: Add Database Functions

// Add new database functions alongside localStorage
async function saveStateToDb() {
  // ... database save logic
}

async function loadStateFromDb() {
  // ... database load logic
}

Step 2: Migrate Existing Data

async function migrateFromLocalStorage() {
  const oldState = localStorage.getItem('app_state');
  if (oldState) {
    try {
      const parsed = JSON.parse(oldState);
      await pt.add('app_ui_state', parsed);
      localStorage.removeItem('app_state'); // Clean up
      console.log('Migrated from localStorage to database');
    } catch (error) {
      console.error('Migration failed:', error);
    }
  }
}

// Run on init
async function initializeApp() {
  await migrateFromLocalStorage();
  await loadStateFromDb();
  renderApp();
}

Step 3: Remove localStorage

Once migration is complete, remove all localStorage code.


Common Patterns Summary

Use Case Pattern Entity Type
Simple UI state Single entity app_ui_state
User preferences Single entity per user user_preferences
Multiple sessions Entity per session session, writing_session
List of items Entity per item task, note, item
Current view/screen Single entity view_state
Form drafts Single entity form_draft

Complete Example: Todo App

// State management for a todo app
const APP_STATE_ENTITY = 'todo_app_state';
let appStateId = null;

const AppState = {
  currentFilter: 'all', // 'all', 'active', 'completed'
  sortBy: 'created',
  viewMode: 'list'
};

// Save UI state
async function saveState() {
  try {
    if (appStateId) {
      await pt.edit(appStateId, AppState, true);
    } else {
      const saved = await pt.add(APP_STATE_ENTITY, AppState);
      appStateId = saved.id;
    }
  } catch (error) {
    console.error('Failed to save state:', error);
  }
}

// Load UI state
async function loadState() {
  try {
    const result = await pt.list({
      entityNames: [APP_STATE_ENTITY],
      limit: 1
    });

    if (result.entities?.length > 0) {
      appStateId = result.entities[0].id;
      Object.assign(AppState, result.entities[0].data);
    }
  } catch (error) {
    console.error('Failed to load state:', error);
  }
}

// Todo operations
async function addTodo(text) {
  const todo = await pt.add('todo', {
    text: text,
    completed: false,
    created_at: new Date().toISOString()
  });
  return todo;
}

async function toggleTodo(todoId) {
  const todo = await pt.get(todoId);
  await pt.edit(todoId, {
    ...todo.data,
    completed: !todo.data.completed
  });
}

async function loadTodos() {
  const result = await pt.list({
    entityNames: ['todo'],
    limit: 100
  });
  return result.entities || [];
}

// Initialize
async function initializeApp() {
  await loadState();
  const todos = await loadTodos();
  renderApp(todos);
}

// Start app
document.addEventListener('DOMContentLoaded', initializeApp);

Checklist for New Apps

When building a new PrimeThink Live App:

  • Never use localStorage - Use chat database instead
  • Define entity types - Choose meaningful names like app_ui_state, task, session
  • Track entity IDs - Store IDs to update existing entities
  • Use merge mode - pt.edit(id, data, true) to preserve fields
  • Await on init - Always await loadState() before rendering
  • Handle errors - Gracefully handle database failures
  • Debounce saves - Don't save on every keystroke
  • Don't serialize functions - Keep runtime-only data in memory
  • Test multi-window - Open app in multiple tabs to verify state sync

Summary

Key Takeaway: Always use the chat database (pt.add, pt.edit, pt.list) instead of localStorage for state persistence in PrimeThink Live Apps. This ensures your app works seamlessly across multiple windows, devices, and users.