Files
GMW/design/patterns/09-state-machines.md
T
asepharyana d59b59a7a7 feat: migrate frontend to Astro + expand AI moderation + backend admin/runtime config
Frontend:
- migrate from Vite to Astro (astro.config.mjs, pages/, layouts/)
- add admin panel, settings page, command palette, error boundary
- refactor App.tsx, MascotChatbot, Sidebar, Header, DashboardLayout
- update API client, WebSocket, auth, dashboard features

Backend:
- add admin module and config routes
- refactor middlewares, Redis connection, WebSocket server/bridge
- add runtime config loader

Discord Gateway:
- refactor AI moderation: circuit breaker, concurrency limiter, fallback processor
- add media analysis client, Seaxng search, user profile learner
- add new drizzle migration

Shared:
- extend database schema, add new config fields
2026-07-02 00:02:41 +07:00

8.3 KiB

State Machines — The Flow of Data

"All happy families are alike; each unhappy family is unhappy in its own way." — Tolstoy, adapted for component states.


🎯 Filosofi State Machine

Setiap komponen data-driven di BETE memiliki 4 state fundamental:

IDLE → LOADING → SUCCESS
              ↘ ERROR
                  ↘ EMPTY (conditional, jika data.length === 0)

State machine memastikan tidak ada kondisi yang terlewat — setiap kemungkinan state visual memiliki representasi.


🎮 The Quad-State Pattern

type DataState<T> =
  | { status: 'idle' }
  | { status: 'loading'; progress?: number }
  | { status: 'success'; data: T; timestamp: number }
  | { status: 'error'; error: Error; retryCount?: number }
  | { status: 'empty'; message?: string };

Generic State Machine Hook

// shared/hooks/useDataState.ts
function useDataState<T>(
  fetcher: () => Promise<T>,
  options?: {
    onSuccess?: (data: T) => void;
    onError?: (error: Error) => void;
    retry?: number;
    cacheKey?: string;
  }
): {
  state: DataState<T>;
  execute: () => Promise<void>;
  reset: () => void;
  retry: () => Promise<void>;
  setData: (data: T) => void;
} {
  const [state, setState] = useState<DataState<T>>({ status: 'idle' });

  const execute = useCallback(async () => {
    setState({ status: 'loading' });
    try {
      const data = await fetcher();
      if (Array.isArray(data) && data.length === 0) {
        setState({ status: 'empty', message: 'No data available' });
      } else {
        setState({ status: 'success', data, timestamp: Date.now() });
        options?.onSuccess?.(data);
      }
    } catch (error) {
      setState({ status: 'error', error: error as Error });
      options?.onError?.(error as Error);
    }
  }, [fetcher]);

  return { state, execute, reset, retry: execute, setData };
}

Component Rendering

function DataPanel() {
  const { state, execute, retry } = useDataState(fetchMessages);

  useEffect(() => { execute(); }, []);

  switch (state.status) {
    case 'idle':
    case 'loading':
      return <LoadingSkeleton />;

    case 'error':
      return (
        <ErrorState
          message={state.error.message}
          onRetry={retry}
          retryCount={state.retryCount}
        />
      );

    case 'empty':
      return <EmptyState message={state.message ?? 'Nothing here'} />;

    case 'success':
      return <DataView data={state.data} />;
  }
}

🖼️ Visual Representations

Loading State

interface LoadingSkeletonProps {
  variant?: 'card' | 'list' | 'detail' | 'table' | 'chart';
  count?: number;   // Jumlah skeleton items
}

/* Contoh variant 'card' */
function CardSkeleton() {
  return (
    <div className="card animate-shimmer" aria-busy="true" aria-label="Loading...">
      <Skeleton className="h-4 w-3/4 mb-3" />
      <Skeleton className="h-3 w-1/2 mb-2" />
      <Skeleton className="h-3 w-full" />
    </div>
  );
}

Error State

interface ErrorStateProps {
  error: Error;
  onRetry: () => void;
  retryCount?: number;
  variant?: 'inline' | 'full-page' | 'toast';
}

function ErrorState({ error, onRetry, retryCount }: ErrorStateProps) {
  const isRetryExhausted = (retryCount ?? 0) >= 3;

  return (
    <div className="flex flex-col items-center gap-4 py-12" role="alert">
      <div className="rounded-full bg-destructive/10 p-3">
        <AlertTriangle className="h-6 w-6 text-destructive" />
      </div>
      <p className="text-sm font-medium text-foreground">Something went wrong</p>
      <p className="text-xs text-muted-foreground">{error.message}</p>
      {!isRetryExhausted ? (
        <Button variant="outline" size="sm" onClick={onRetry}>
          Try Again
        </Button>
      ) : (
        <p className="text-xs text-muted-foreground">
          Still failing after multiple attempts. Please try again later.
        </p>
      )}
    </div>
  );
}

Empty State

interface EmptyStateProps {
  icon?: ReactNode;
  title: string;
  description?: string;
  action?: { label: string; onClick: () => void };
  variant?: 'mascot' | 'icon' | 'minimal';
}

const EMPTY_STATES = {
  messages: { icon: MessageSquare, title: 'No messages yet', description: 'Messages will appear here once they are captured.' },
  speakers: { icon: Mic, title: 'No active speakers', description: 'Quiet in here...' },
  recordings: { icon: Radio, title: 'No recordings', description: 'Join a voice channel to start recording.' },
  analytics: { icon: BarChart3, title: 'Not enough data', description: 'Analytics will populate as data accumulates.' },
  users: { icon: Users, title: 'No users found', description: 'Try adjusting your filters.' },
};

♻️ State Transition Diagram

        ┌──────────┐
        │   IDLE   │
        └────┬─────┘
             │ execute()
             ↓
        ┌──────────┐
        │ LOADING  │◄────── retry()
        └────┬─────┘
             │
     ┌───────┴───────────┐
     │                   │
     ↓                   ↓
┌──────────┐      ┌──────────┐
│ SUCCESS  │      │  ERROR   │
│ data: T  │      │ err: E   │
└────┬─────┘      └────┬─────┘
     │                  │
     │ (data.length     │ retry()
     │  === 0)          │
     ↓                  │
┌──────────┐            │
│  EMPTY   │            │
│ msg: str │            │
└──────────┘            │
     │                  │
     └──────┬───────────┘
            │ reset()
            ↓
        ┌──────────┐
        │   IDLE   │
        └──────────┘

🔄 Retry Strategy

const RETRY_CONFIG = {
  maxAttempts: 3,
  baseDelay: 1000,      // 1s
  maxDelay: 10000,      // 10s
  backoff: 'exponential' as const,
  onRetry: (attempt: number, error: Error) => {
    logger.warn(`Retry attempt ${attempt}`, { error: error.message });
  },
};

Exponential Backoff

function calculateDelay(attempt: number): number {
  return Math.min(
    1000 * Math.pow(2, attempt - 1),  // 1s, 2s, 4s
    10000  // cap at 10s
  );
}

📦 Component State Map

Component Loading Error Empty Success
MessageFeed Card skeletons ErrorState + retry Mascot "No messages" Message list
VoiceCards Card skeletons ErrorState "No connected channels" VoiceCard list
ActiveSpeakers Dot skeletons Silent fallback "No speakers" Speaker list
Analytics Skeleton grid ErrorState "Not enough data" Charts
Recordings List skeletons ErrorState "No recordings" Recording list
UserList List skeletons ErrorState + retry "No users found" User list
DashboardStats Stat skeletons ErrorState "No data available" Stat grid

⚠️ Anti-Patterns State

Missing state handling

// ❌ JANGAN — hanya handle SUCCESS
function Panel() {
  const { data, isLoading } = useQuery(...);
  if (isLoading) return <Spinner />;
  return <DataView data={data} />;  // ERROR? EMPTY?
}

Loading state after error

// ❌ JANGAN — loading infinite loop setelah error
function Panel() {
  const { data, isLoading } = useQuery(..., { retry: true });
  if (isLoading) return <Spinner />;
  // ERROR: retry=true + error = loading terus
}

Empty state default terlalu generic

// ❌ JANGAN — tidak helpful
<div>No data</div>

// ✅ Kontekstual dengan action
<EmptyState icon={MessageSquare} title="No messages" action={{ label: "Refresh", onClick: refetch }} />

🔗 Referensi

Sumber Konsep
State Reducer Pattern Advanced state management
XState Visual state machines
React useReducer Built-in state management

"Setiap state adalah babak dalam cerita data — dari sunyi hingga berbicara." ❄️🩵