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
interface StateContainer<TData> {
meta: StateMeta;
data: TData;
errors: StateErrors;
}meta — Component Metadata
Tracks loading state, initialization, and timestamps:
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:
// 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:
// Example
{ firstName: 'First name is required', email: 'Invalid email' }Available Helpers
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
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.tssuper-user-form.stores.tsotp-form.stores.tssettings-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