Skip to content

State Container Pattern

File: resources/js/types/state.ts

Every store in the application follows the StateContainer<TData> pattern. This ensures consistency across all modules.

Why It Exists

Without a standard pattern, every developer would invent their own state shape. Some would use flat objects, others would nest differently, making stores unpredictable. StateContainer enforces a single structure so any developer can open any store file and immediately understand it.

The Container Shape

typescript
interface StateContainer<TData> {
    meta: StateMeta;
    data: TData;
    errors: StateErrors;
}

meta — Component Metadata

Tracks loading state, initialization, and timestamps:

typescript
interface StateMeta {
    loading: boolean;      // Is an async operation in progress?
    initialized: boolean;  // Has the store been initialized?
    updatedAt: string | null;  // ISO timestamp of last update
    errorAt: string | null;    // ISO timestamp of last error
}

Why this matters: Components use meta.loading to show spinners, meta.initialized to know when to render, and meta.errorAt to display error states.

data — Domain Data

The actual payload. Each store defines its own shape:

typescript
// Example: TutorialStore
interface TutorialStoreData {
    checks: CheckItem[];
    activeStep: number;
    totalSteps: number;
}

errors — Validation / Server Errors

Flat key-value map where keys are field names and values are error messages:

typescript
// Example
{ firstName: 'First name is required', email: 'Invalid email' }

Available Helpers

typescript
import { initialMeta, loadingMeta, metaFrom, createState, hasErrors } from '@/types/state';

// Create a fresh container with default meta
const state = createState<MyData>({ items: [], count: 0 });

// Check for errors
if (hasErrors(state.errors)) { /* show errors */ }

Store Architecture Rules

┌──────────────┐
│  Component   │  ← subscribe() + call action
├──────────────┤
│    Store     │  ← manages StateContainer, delegates logic
├──────────────┤
│   Service    │  ← pure functions, orchestration
├──────────────┤
│     API      │  ← HTTP fetch only
└──────────────┘

How to Create a Store

typescript
import { initialMeta } from '@/types/state';
import type { StateContainer } from '@/types/state';

// 1. Define your data shape
interface MyData { items: string[]; sending: boolean; }
type MyContainer = StateContainer<MyData>;

// 2. Create initial state
function createState(): MyContainer {
    return {
        meta: { ...initialMeta, initialized: true },
        data: { items: [], sending: false },
        errors: {},
    };
}

// 3. Build the store class
type Listener = (state: MyContainer) => void;

class MyStore {
    #state = createState();
    #listeners = new Set<Listener>();

    get state() { return this.#state; }

    // Actions
    setItems(items: string[]) {
        this.#state.data.items = items;
        this.#state.meta.updatedAt = new Date().toISOString();
        this.#emit();
    }

    // Subscriptions
    subscribe(l: Listener) {
        this.#listeners.add(l);
        return () => this.#listeners.delete(l);
    }

    #emit() {
        this.#listeners.forEach(l => l(this.#state));
    }
}

File Naming

Store files follow the pattern: <action>-<module>.stores.ts

Examples:

  • fetch-bulletin.stores.ts
  • super-user-form.stores.ts
  • otp-form.stores.ts
  • settings-form.stores.ts

Store groups live in subdirectories with barrel index.ts:

stores/super-user-entry/
├── super-user-form.stores.ts
├── super-user-form-validation.stores.ts
└── index.ts