Files
metabuilder/docs/reference/STATE_MANAGEMENT_GUIDE.md
johndoe6345789 b3e17e7dd4 feat: Add troubleshooting guide and enhance act scripts
- Created a new troubleshooting guide in README.md for common issues and testing problems.
- Updated package.json to include new act commands for linting, type checking, building, and diagnosing workflows.
- Added a pre-commit hook script to validate workflows before commits.
- Enhanced run-act.sh script with logging, Docker checks, and improved output formatting.
- Improved test-workflows.sh with an interactive menu and performance tracking.
- Introduced setup-act.sh for quick setup and testing of act integration.
2025-12-25 13:16:45 +00:00

24 KiB
Raw Blame History

🎭 State Management Consolidation Guide

Document Date: December 25, 2025
Purpose: Unified strategy for scattered state across components, context, and database
Target: Consistent, testable, debuggable state patterns


🎯 Current Situation

State Scatter Map

Component State (useState)        [LOCAL]
├─ UI state (expanded, focused)
├─ Form inputs
├─ Modal open/closed
└─ Temporary data

Context API                       [GLOBAL]
├─ Auth context
├─ Theme context
├─ User settings
└─ Notifications

Database State (Prisma)           [PERSISTENT]
├─ User data
├─ Configuration
├─ Preferences
└─ Audit logs

Third-party Libraries             [EXTERNAL]
├─ React Query (caching)
├─ React Hook Form (forms)
├─ Redux (if used)
└─ Zustand (if used)

Problems:

  1. No clear decision criteria → inconsistent choices
  2. Overlapping patterns → duplicate state
  3. No caching strategy → unnecessary database calls
  4. Context sprawl → 10+ providers
  5. No clear data flow → hard to debug

Unified State Architecture

4 State Categories

┌─────────────────────────────────────────────────────────┐
│ STATE MANAGEMENT HIERARCHY                              │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  CATEGORY 1: LOCAL STATE (useState)                     │
│  Duration: Session                                      │
│  Scope: Single component                                │
│  Persistence: None                                      │
│  Max items: 2-3 per component                           │
│  ✓ UI toggles, temporary form data                      │
│                                                         │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  CATEGORY 2: GLOBAL STATE (Context)                     │
│  Duration: Session                                      │
│  Scope: Entire app                                      │
│  Persistence: Session storage                           │
│  Max providers: 3-4                                     │
│  ✓ Auth, theme, notifications                          │
│                                                         │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  CATEGORY 3: DATABASE STATE (Prisma + React)           │
│  Duration: Permanent                                    │
│  Scope: Multi-session                                   │
│  Persistence: Database                                  │
│  ✓ User data, configs, preferences                      │
│                                                         │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  CATEGORY 4: CACHE STATE (React Query)                 │
│  Duration: Temporary with invalidation                  │
│  Scope: Single or multiple components                   │
│  Persistence: Memory                                    │
│  ✓ API responses, expensive queries                     │
│                                                         │
└─────────────────────────────────────────────────────────┘

📋 Decision Tree

START: I need to store some data

    Is this data user-specific or tenant-specific?
    ├─ NO → Is it needed globally across the app?
    │      ├─ NO → LOCAL STATE (useState)
    │      │       └─ Keep it simple, short-lived
    │      └─ YES → Is it frequently accessed?
    │              ├─ YES → GLOBAL STATE (Context)
    │              │        └─ Auth, theme, settings
    │              └─ NO → CACHE STATE (React Query)
    │                      └─ Fetch once, reuse
    │
    └─ YES → Will it outlive the current session?
            ├─ NO → LOCAL STATE or CACHE
            │       └─ Temporary user interactions
            └─ YES → DATABASE STATE (Prisma)
                    └─ Save to DB, load on app start

1 LOCAL STATE: useState

Definition

Ephemeral state that lives only during current component render cycle.

When to Use

// ✅ DO: Use for UI state
const [isExpanded, setIsExpanded] = useState(false);
const [activeTab, setActiveTab] = useState(0);
const [hoverIndex, setHoverIndex] = useState(-1);

// ✅ DO: Use for form inputs before submission
const [firstName, setFirstName] = useState('');
const [email, setEmail] = useState('');

// ✅ DO: Use for temporary data
const [searchResults, setSearchResults] = useState([]);

// ❌ DON'T: Use for data that should persist
const [userPreferences, setUserPreferences] = useState({});
// → Move to DATABASE STATE

// ❌ DON'T: Use for app-wide settings
const [isDarkMode, setIsDarkMode] = useState(false);
// → Move to GLOBAL STATE or DATABASE STATE

// ❌ DON'T: Duplicate server state locally
const [userData, setUserData] = useState(null);
// → Use CACHE STATE with React Query

Patterns

Simple Toggle:

export function AccordionItem({ title, children }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen ? '▼' : '▶'} {title}
      </button>
      {isOpen && <div>{children}</div>}
    </div>
  );
}

Form Inputs:

export function UserForm({ onSubmit }) {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      onSubmit({ name, email });
      setName(''); // Clear after submit
      setEmail('');
    }}>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <button type="submit">Submit</button>
    </form>
  );
}

Multiple Related States (Use Object):

export function Modal() {
  const [state, setState] = useState({
    isOpen: false,
    position: { x: 0, y: 0 },
    size: { width: 500, height: 300 },
  });

  const setIsOpen = (isOpen) =>
    setState((s) => ({ ...s, isOpen }));

  const setPosition = (position) =>
    setState((s) => ({ ...s, position }));

  // ... use state.isOpen, setIsOpen, setPosition
}

Limits

  • Max per component: 2-3 items
  • Max data size: < 1 KB
  • Duration: Current session only
  • Scope: Single component only

2 GLOBAL STATE: Context API

Definition

Application-wide state shared across many components without prop drilling.

When to Use

// ✅ DO: Authentication context
const AuthContext = createContext();
// → User data, tokens, login/logout functions

// ✅ DO: Theme context
const ThemeContext = createContext();
// → Dark/light mode, color scheme

// ✅ DO: Notifications context
const NotificationsContext = createContext();
// → Toast/alert messages, dismissal

// ❌ DON'T: Component-specific state
// → Keep in component with useState

// ❌ DON'T: Frequently changing data
// → Use CACHE STATE instead, Context updates = expensive re-renders

// ❌ DON'T: Server state
// → Use CACHE STATE with React Query

Maximum 3-4 Global Contexts

┌─ AuthContext
│  ├─ user
│  ├─ isAuthenticated
│  └─ login/logout
├─ ThemeContext
│  ├─ isDarkMode
│  └─ setDarkMode
├─ NotificationsContext
│  ├─ notifications[]
│  ├─ addNotification
│  └─ removeNotification
└─ (Optional) SettingsContext
   ├─ userSettings
   └─ updateSettings

Never add:

  • Global component visibility state
  • Global form state
  • Global selection state
  • Global search state

Pattern: AuthContext

// src/context/AuthContext.tsx
import { createContext, useContext, useState, useEffect } from 'react';

interface AuthContextType {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  // Restore user from session storage on mount
  useEffect(() => {
    const stored = sessionStorage.getItem('user');
    if (stored) {
      setUser(JSON.parse(stored));
    }
    setIsLoading(false);
  }, []);

  const login = async (email: string, password: string) => {
    setIsLoading(true);
    try {
      const response = await api.post('/auth/login', { email, password });
      setUser(response.user);
      sessionStorage.setItem('user', JSON.stringify(response.user));
    } finally {
      setIsLoading(false);
    }
  };

  const logout = () => {
    setUser(null);
    sessionStorage.removeItem('user');
  };

  return (
    <AuthContext.Provider
      value={{
        user,
        isAuthenticated: !!user,
        isLoading,
        login,
        logout,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
}

// Custom hook for using auth
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

Usage in components:

export function Header() {
  const { user, isAuthenticated, logout } = useAuth();

  if (!isAuthenticated) {
    return <div>Not logged in</div>;
  }

  return (
    <header>
      <span>Welcome, {user?.name}</span>
      <button onClick={logout}>Logout</button>
    </header>
  );
}

3 DATABASE STATE: Prisma + React State

Definition

Persistent data stored in database, loaded into React state, cached during session.

When to Use

// ✅ DO: User preferences
const [userPrefs, setUserPrefs] = useState<UserPreferences | null>(null);
useEffect(() => {
  Database.getUserPreferences(userId).then(setUserPrefs);
}, [userId]);

// ✅ DO: Configuration data
const [config, setConfig] = useState<Config | null>(null);
useEffect(() => {
  Database.getConfig(tenantId).then(setConfig);
}, [tenantId]);

// ✅ DO: Lists of items from database
const [items, setItems] = useState<Item[]>([]);
useEffect(() => {
  Database.getItems(tenantId).then(setItems);
}, [tenantId]);

// ❌ DON'T: Cache without React Query
// → Too complex to manage updates/refetch

// ❌ DON'T: Forget tenantId in queries
const items = await Database.getItems(); // ❌ WRONG
const items = await Database.getItems({ tenantId }); // ✅ RIGHT

Pattern: Database State Hook

// src/hooks/useUserPreferences.ts
export interface UserPreferences {
  theme: 'light' | 'dark';
  language: string;
  notifications: boolean;
}

export function useUserPreferences(userId: string) {
  const [prefs, setPrefs] = useState<UserPreferences | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  // Load preferences on mount
  useEffect(() => {
    const load = async () => {
      try {
        setLoading(true);
        const data = await Database.getUserPreferences(userId);
        setPrefs(data);
      } catch (err) {
        setError(err as Error);
      } finally {
        setLoading(false);
      }
    };
    load();
  }, [userId]);

  // Update preferences (write-through cache)
  const updatePrefs = useCallback(
    async (updates: Partial<UserPreferences>) => {
      try {
        const updated = await Database.updateUserPreferences(userId, updates);
        setPrefs(updated);
      } catch (err) {
        setError(err as Error);
        // Revert optimistic update if it fails
        const current = await Database.getUserPreferences(userId);
        setPrefs(current);
      }
    },
    [userId]
  );

  return { prefs, loading, error, updatePrefs };
}

Usage:

export function SettingsPanel() {
  const { user } = useAuth();
  const { prefs, loading, updatePrefs } = useUserPreferences(user.id);

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      <label>
        Dark Mode:
        <input
          type="checkbox"
          checked={prefs?.theme === 'dark'}
          onChange={(e) =>
            updatePrefs({ theme: e.target.checked ? 'dark' : 'light' })
          }
        />
      </label>
    </div>
  );
}

Database Schema Example

// prisma/schema.prisma

model UserPreferences {
  id          String @id @default(cuid())
  userId      String @unique
  user        User @relation(fields: [userId], references: [id])
  tenantId    String
  
  // Preferences data
  theme       String @default("light") // 'light' | 'dark'
  language    String @default("en")
  notifications Boolean @default(true)
  sidebarWidth Int @default(250)
  
  // Metadata
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  
  @@unique([userId, tenantId])
}

4 CACHE STATE: React Query

Definition

Temporary cached state for API/database queries with automatic invalidation.

When to Use

// ✅ DO: API responses
const { data: workflows } = useQuery({
  queryKey: ['workflows', userId],
  queryFn: () => api.get(`/workflows?user=${userId}`),
});

// ✅ DO: Expensive database queries
const { data: analytics } = useQuery({
  queryKey: ['analytics', dateRange],
  queryFn: () => Database.getAnalytics(dateRange),
});

// ✅ DO: Frequently changing data
const { data: notifications } = useQuery({
  queryKey: ['notifications'],
  queryFn: () => api.get('/notifications'),
  refetchInterval: 5000, // Poll every 5 seconds
});

// ❌ DON'T: Use for always-fresh data
// → Use state + useEffect instead

// ❌ DON'T: Forget queryKey
const { data } = useQuery({
  queryFn: () => fetch('/api/data'),
  // ❌ No queryKey means no caching
});

Pattern: React Query Hook

// src/hooks/useWorkflows.ts
import { useQuery } from '@tanstack/react-query';

export function useWorkflows(userId: string) {
  return useQuery({
    queryKey: ['workflows', userId],
    queryFn: async () => {
      const response = await Database.getWorkflows({ userId });
      return response;
    },
    staleTime: 1000 * 60 * 5, // 5 minutes
    gcTime: 1000 * 60 * 10,   // 10 minutes cache
  });
}

export function useWorkflowDetails(workflowId: string) {
  return useQuery({
    queryKey: ['workflow', workflowId],
    queryFn: async () => {
      return Database.getWorkflow(workflowId);
    },
    enabled: !!workflowId, // Don't fetch if no ID
  });
}

export function useUpdateWorkflow() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: UpdateWorkflowData) =>
      Database.updateWorkflow(data),
    onSuccess: (newData) => {
      // Invalidate related queries
      queryClient.invalidateQueries({
        queryKey: ['workflows'],
      });
      // Update specific query
      queryClient.setQueryData(['workflow', newData.id], newData);
    },
  });
}

Usage:

export function WorkflowsList() {
  const { data: workflows, isLoading, error } = useWorkflows('user-123');
  const updateMutation = useUpdateWorkflow();

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {workflows?.map((workflow) => (
        <div key={workflow.id}>
          <h3>{workflow.name}</h3>
          <button
            onClick={() =>
              updateMutation.mutate({
                id: workflow.id,
                status: 'paused',
              })
            }
            disabled={updateMutation.isPending}
          >
            Pause
          </button>
        </div>
      ))}
    </div>
  );
}

Setup QueryClient

// src/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60, // 1 minute
      gcTime: 1000 * 60 * 10, // 10 minutes
      retry: 1,
      retryDelay: (attemptIndex) =>
        Math.min(1000 * 2 ** attemptIndex, 30000),
    },
  },
});

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  );
}

🔄 State Flow Diagram

┌─ USER ACTION (click, input, etc)
│
├─ LOCAL STATE (useState) ──────────→ Update immediately
│                                     └─ Re-render component
│
├─ GLOBAL STATE (Context) ──────────→ Update provider
│                                     └─ Re-render all consumers
│
├─ DATABASE STATE (Prisma + useState)
│  ├─ Read: Load on mount
│  │  └─ useState → Re-render
│  └─ Write: Save to DB
│     ├─ Optimistic update to state
│     ├─ Database operation
│     └─ Invalidate cache if fails
│
└─ CACHE STATE (React Query)
   ├─ Read: useQuery
   │  ├─ Return cached if fresh
   │  └─ Fetch if stale
   ├─ Write: useMutation
   │  ├─ Optimistic update
   │  ├─ Server operation
   │  ├─ Success: invalidate queries
   │  └─ Error: rollback
   └─ Auto-invalidate: staleTime expiry

📊 State Pattern Decision Matrix

Scenario Local Context Database Cache
UI toggle
Form input
Current user
App settings
Theme
User preferences
API response
Search results
Analytics data
List of items
Notification

⚠️ Anti-Patterns to Avoid

Anti-Pattern 1: Global Context for Everything

// ❌ WRONG: Monolithic context with everything
const GlobalContext = createContext();
// Contains: user, theme, notifications, modals, forms, ...

// Result: Re-renders entire app on any change ❌

Fix: Split into separate concerns

// ✅ RIGHT: Separate contexts
const AuthContext = createContext();      // User info
const ThemeContext = createContext();     // Theme settings
const NotificationsContext = createContext(); // Alerts

Anti-Pattern 2: Database Reads Scattered

// ❌ WRONG: Each component fetches directly
function ComponentA() {
  const [data, setData] = useState(null);
  useEffect(() => {
    Database.getData().then(setData);
  }, []);
}

function ComponentB() {
  const [data, setData] = useState(null);
  useEffect(() => {
    Database.getData().then(setData); // Same query, duplicate fetch!
  }, []);
}

Fix: Use custom hook or React Query

// ✅ RIGHT: Centralized query
export function useData() {
  return useQuery({
    queryKey: ['data'],
    queryFn: () => Database.getData(),
  });
}

function ComponentA() {
  const { data } = useData(); // Uses cache
}

function ComponentB() {
  const { data } = useData(); // Same cached result
}

Anti-Pattern 3: Forgetting tenantId

// ❌ WRONG: No tenant isolation
const items = await Database.getItems();

// This might return items from OTHER tenants!

Fix: Always include tenantId

// ✅ RIGHT: Tenant-aware query
const items = await Database.getItems({
  tenantId: currentUser.tenantId,
});

Anti-Pattern 4: useState for Server State

// ❌ WRONG: Duplicate server state locally
const [user, setUser] = useState(null);
useEffect(() => {
  Database.getUser(userId).then(setUser);
}, [userId]);

// Problem: Manual cache invalidation needed ❌

Fix: Use React Query

// ✅ RIGHT: Let React Query manage cache
const { data: user } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => Database.getUser(userId),
});

// Automatic cache management ✅

🎯 Migration Checklist

Step 1: Audit (1 week)

  • List all useState instances
  • List all useContext instances
  • Map all Database reads
  • Identify cache opportunities

Step 2: Define (1 week)

  • Choose context structure (max 3-4)
  • Define database models
  • Set up React Query
  • Document patterns

Step 3: Implement (2-3 weeks)

  • Create standard hooks
  • Migrate component state
  • Migrate context usage
  • Add React Query

Step 4: Test (1 week)

  • Unit tests for hooks
  • Integration tests
  • E2E tests
  • Performance testing

Step 5: Deploy (Ongoing)

  • Gradual rollout
  • Monitor performance
  • Fix issues
  • Document learnings

📚 Templates

Custom Hook Template

// src/hooks/use[Name].ts
import { useCallback, useEffect, useState } from 'react';

export interface [Name]State {
  // Define your state shape
  data: any;
  loading: boolean;
  error: Error | null;
}

export function use[Name](/* params */) {
  const [state, setState] = useState<[Name]State>({
    data: null,
    loading: false,
    error: null,
  });

  // Load data on mount
  useEffect(() => {
    // Load logic
  }, [/* dependencies */]);

  // Action handlers
  const action = useCallback(async (/* params */) => {
    // Action logic
  }, []);

  return { ...state, action };
}

Context Provider Template

// src/context/[Name]Context.tsx
import { createContext, useContext, useState } from 'react';

interface [Name]ContextType {
  // Define your context
}

const [Name]Context = createContext<[Name]ContextType | undefined>(undefined);

export function [Name]Provider({ children }) {
  const [state, setState] = useState({
    // Initial state
  });

  return (
    <[Name]Context.Provider value={{ state, setState }}>
      {children}
    </[Name]Context.Provider>
  );
}

export function use[Name]() {
  const context = useContext([Name]Context);
  if (!context) {
    throw new Error('use[Name] must be used within [Name]Provider');
  }
  return context;
}

🚀 Implementation Order

  1. Setup React Query (4 hours)

    • Install packages
    • Create QueryClient
    • Set up provider
    • Add dev tools
  2. Create Context Structure (6 hours)

    • AuthContext
    • ThemeContext
    • NotificationsContext
    • Custom hooks
  3. Migrate Database State (20 hours)

    • Identify all database reads
    • Create hooks for each query
    • Replace scattered useState
    • Add React Query where applicable
  4. Consolidate Component State (15 hours)

    • Audit all useState usage
    • Move global state to context
    • Move persistent state to database
    • Keep only UI state local
  5. Testing (10 hours)

    • Unit tests for hooks
    • Integration tests
    • E2E tests

Success Metrics

  • Max 3-4 global context providers
  • 80% reduction in useState instances in containers
  • 100% of database reads use hooks
  • 90%+ cache hit rate for repeated queries
  • Zero duplicate database fetches
  • All multi-tenant queries include tenantId
  • <100ms time to interactive
  • Zero console warnings/errors

Generated: December 25, 2025
Next Review: After Phase 2 completion