apprun
Version:
JavaScript library that has Elm inspired architecture, event pub-sub and components
603 lines (514 loc) • 16.7 kB
Markdown
# AppRun Component Creation Guide
## Core Architecture Patterns
AppRun follows the State-View-Update architecture with TypeScript support. Choose the appropriate component pattern based on your needs:
### **Stateful Class Components (Self-Contained)**
Use for components that manage their own state internally, handle side effects, and coordinate with parents via global events. Modern pattern using `mounted` lifecycle.
### **Container Class Components (Legacy)**
Use for components that manage state, handle side effects, and coordinate data flow using traditional state initialization.
### **Functional Components (Presentation/Dumb Components)**
Use for components that only render UI based on props with minimal logic.
## Component Patterns
### **Pattern 1: Stateful Class Component (Modern - Recommended)**
Use `mounted` lifecycle to receive props and convert to initial state. Functions defined at module level for better testing.
#### **Module-Level Functions (separate file)**
```typescript
// component-functions.ts
export interface ComponentState {
mode: 'create' | 'edit' | 'delete';
formData: FormType;
loading: boolean;
error: string | null;
successMessage: string | null;
}
export interface ComponentProps {
mode?: string;
initialData?: FormType;
}
export const initializeState = (props: ComponentProps): ComponentState => ({
mode: props.mode || 'create',
formData: props.initialData || getDefaultFormData(),
loading: false,
error: null,
successMessage: null
});
export const saveData = async function* (state: ComponentState): AsyncGenerator<ComponentState> {
yield { ...state, loading: true, error: null };
try {
await performSave(state.formData);
yield {
...state,
loading: false,
successMessage: 'Data saved successfully!'
};
setTimeout(() => app.run('data-saved'), 2000);
} catch (error) {
yield { ...state, loading: false, error: error.message };
}
};
export const closeModal = (): void => {
app.run('close-modal');
};
```
#### **Component Structure**
```typescript
// component.tsx
import { initializeState, saveData, closeModal, type ComponentState, type ComponentProps } from './component-functions';
export default class MyComponent extends Component<ComponentState> {
declare props: Readonly<ComponentProps>;
mounted = (props: ComponentProps): ComponentState => initializeState(props);
view = (state: ComponentState) => {
// Guard clauses
if (state.successMessage) {
return (
<div className="modal-backdrop" $onclick={[closeModal]}>
<div className="success-message">
<p>{state.successMessage}</p>
</div>
</div>
);
}
return (
<div className="modal-backdrop" $onclick={[closeModal]}>
<form>
<input
value={state.formData.name}
$bind="formData.name"
disabled={state.loading}
/>
<button
$onclick={[saveData]}
disabled={state.loading}
>
{state.loading ? 'Saving...' : 'Save'}
</button>
<button $onclick={[closeModal]}>Cancel</button>
</form>
</div>
);
};
}
```
### **Pattern 2: Legacy Container Component**
Traditional pattern with state initialization function.
#### **Component Structure**
```typescript
export default class MyComponent extends Component<ComponentState> {
state = async (): Promise<ComponentState> => {
// Initial state with async data loading
try {
const data = await loadData();
return {
data,
loading: false,
error: null,
selectedItem: null,
isEditing: false
};
} catch (error) {
return {
data: [],
loading: false,
error: error.message,
selectedItem: null,
isEditing: false
};
}
};
view = (state: ComponentState) => {
// Guard clauses for early returns
if (state.loading) return <div>Loading...</div>;
if (state.error) return <div>Error: {state.error}</div>;
if (state.data.length === 0) return <div>No data</div>;
// Main content
return (
<div>
{/* Render main UI */}
<PresentationComponent
data={state.data}
selectedItem={state.selectedItem}
onSelect={(item) => this.run('select-item', item)}
/>
</div>
);
};
update = {
'select-item': (state: ComponentState, item: DataType): ComponentState => ({
...state,
selectedItem: item
}),
'async-action': async function* (state: ComponentState): AsyncGenerator<ComponentState> {
try {
yield { ...state, loading: true, error: null };
const result = await performAsyncAction();
yield { ...state, loading: false, data: result };
} catch (error) {
yield { ...state, loading: false, error: error.message };
}
},
'side-effect-action': (state: ComponentState): void => {
// No return value = no re-render
window.location.href = '/new-page';
}
};
}
```
### **Pattern 3: Functional Presentation Component**
```typescript
interface ComponentProps {
data: DataType[];
selectedItem: DataType | null;
loading?: boolean;
error?: string | null;
// Event handlers
onSelect?: (item: DataType) => void;
onDelete?: (id: string) => void;
}
export default function MyPresentationComponent(props: ComponentProps) {
const {
data,
selectedItem,
loading = false,
error = null,
onSelect,
onDelete
} = props;
// Guard clauses
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (data.length === 0) return <div>No data</div>;
return (
<div>
{data.map(item => (
<div
key={item.id}
$onclick={['select-item', item]}
className={selectedItem?.id === item.id ? 'selected' : ''}
>
{item.name}
<button $onclick={['delete-item', item.id]}>Delete</button>
</div>
))}
</div>
);
}
```
## Event Handling Rules
### **✅ DO: Use $on Directives for State Updates**
```typescript
// String actions (handled in parent's update object)
$onclick="action-name"
$oninput="update-field"
// Tuple actions (pass data to handler)
$onclick={['action-name', data]}
$oninput={['update-field', 'fieldName']}
$onchange={['update-dropdown', 'provider']}
// Direct function references (modern pattern - recommended)
$onclick={[saveFunction]}
$onclick={[deleteFunction]}
$onclick={[closeModal]}
```
### **✅ DO: Use $bind for Two-Way Data Binding**
```typescript
// Automatic form field binding (modern pattern)
<input
value={state.formData.name}
$bind="formData.name"
/>
<textarea
value={state.formData.description}
$bind="formData.description"
/>
<select
value={state.formData.provider}
$bind="formData.provider"
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
```
### **✅ DO: Use Regular Properties for Non-State Actions**
```typescript
// DOM manipulation only
onclick={(e) => e.stopPropagation()}
onmouseenter={(e) => e.target.focus()}
// Side effects (functions that call app.run() or navigate)
onclick={handleSideEffect} // function calls app.run() internally
onclick={() => window.open('/new-page')}
// Event prevention
onsubmit={(e) => e.preventDefault()}
```
### **❌ DON'T: Mix Patterns Incorrectly**
```typescript
// ❌ Don't use $on with app.run() calls
$onclick={() => app.run('action')}
$onclick={(e) => this.run('action', e.target.value)}
// ❌ Don't use regular props for state updates
onclick="action-name" // Use $onclick for state updates
// ❌ Don't use arrow functions for simple state updates
$onclick={(e) => ({ ...state, field: e.target.value })}
// ❌ Don't use manual form handlers when $bind is available
$oninput={(e) => updateField('name', e)} // Use $bind="formData.name" instead
```
## Update Function Patterns
### **Module-Level State Update Functions (Modern - Recommended)**
```typescript
// Export functions for direct references in $on directives
export const saveData = async function* (state: State): AsyncGenerator<State> {
yield { ...state, loading: true, error: null };
try {
await performSave(state.formData);
yield {
...state,
loading: false,
successMessage: 'Data saved successfully!'
};
// Global event for parent coordination
setTimeout(() => app.run('data-saved'), 2000);
} catch (error) {
yield { ...state, loading: false, error: error.message };
}
};
export const deleteData = async function* (state: State): AsyncGenerator<State> {
yield { ...state, loading: true, error: null };
try {
await performDelete(state.formData.id);
yield {
...state,
loading: false,
successMessage: 'Data deleted successfully!'
};
setTimeout(() => app.run('data-deleted'), 2000);
} catch (error) {
yield { ...state, loading: false, error: error.message };
}
};
export const closeModal = (): void => {
app.run('close-modal'); // Global event for parent
};
```
### **Legacy Update Functions (Traditional Pattern)**
```typescript
// Synchronous state update
'action-name': (state: State, payload?: any): State => ({
...state,
// immutable updates
field: newValue
}),
// Async progressive updates
'async-action': async function* (state: State, payload?: any): AsyncGenerator<State> {
try {
yield { ...state, loading: true, error: null };
const result = await asyncOperation(payload);
yield { ...state, loading: false, data: result };
} catch (error) {
yield { ...state, loading: false, error: error.message };
}
},
// Side effect (no re-render)
'navigate-action': (state: State, path: string): void => {
window.location.href = path;
},
// Form field updates (common pattern)
'update-form-field': (state: State, field: string, event: Event): State => {
const target = event.target as HTMLInputElement;
const value = target.type === 'number' ? parseFloat(target.value) || 0 : target.value;
return {
...state,
formData: {
...state.formData,
[field]: value
}
};
}
```
## Component Composition Patterns
### **Parent-Child with Global Events (Modern - Recommended)**
```typescript
// Parent Component (simplified state)
export default class WorldComponent extends Component<WorldState> {
view = (state: WorldState) => (
<div>
{/* Core world UI */}
<div className="world-content">
{state.agents.map(agent => (
<div key={agent.id} $onclick={['open-agent-edit', agent]}>
{agent.name}
</div>
))}
</div>
{/* Conditional modal rendering */}
{state.showAgentEdit &&
<AgentEdit
agent={state.selectedAgent}
mode={state.editMode}
worldName={state.worldName}
/>
}
</div>
);
update = {
'open-agent-edit': (state, agent) => ({
...state,
showAgentEdit: true,
editMode: 'edit',
selectedAgent: agent
}),
'close-agent-edit': (state) => ({
...state,
showAgentEdit: false
}),
// Global events from child components
'agent-saved': async (state) => {
const agents = await getAgents(state.worldName);
return { ...state, agents, showAgentEdit: false };
},
'agent-deleted': async (state) => {
const agents = await getAgents(state.worldName);
return { ...state, agents, showAgentEdit: false };
}
};
}
// Self-contained child component
export default class AgentEdit extends Component<AgentEditState> {
mounted = (props) => initializeState(props);
view = (state) => (
<div className="modal-backdrop" $onclick={[closeModal]}>
<form>
<input $bind="formData.name" />
<button $onclick={[saveAgent]}>Save</button>
</form>
</div>
);
}
```
### **Container + Presentation Pattern (Legacy)**
```typescript
// Container Component (manages state)
export default class WorldComponent extends Component<WorldState> {
view = (state: WorldState) => (
<div>
<WorldChat
messages={state.messages}
userInput={state.userInput}
onSendMessage={(text) => this.run('send-message', text)}
/>
<WorldSettings
world={state.world}
selectedAgent={state.selectedAgent}
onEditAgent={(agent) => this.run('edit-agent', agent)}
/>
</div>
);
}
// Presentation Components (stateless)
function WorldChat(props: WorldChatProps) { /* render only */ }
function WorldSettings(props: WorldSettingsProps) { /* render only */ }
```
## State Management Rules
### **✅ DO: Immutable Updates**
```typescript
// Spread operator for updates
{ ...state, field: newValue }
// Nested object updates
{
...state,
nested: {
...state.nested,
field: newValue
}
}
// Array updates
{
...state,
items: [...state.items, newItem],
filteredItems: state.items.filter(item => item.id !== deletedId)
}
```
### **✅ DO: Defensive Programming**
```typescript
// Safe array operations
messages: state.messages || []
count: (state.items || []).length
// Safe object access
selectedItem: state.selectedItem?.name || 'None'
// Default props in functional components
const { data = [], loading = false } = props;
```
### **❌ DON'T: Mutate State**
```typescript
// ❌ Don't mutate existing state
state.field = newValue;
state.items.push(newItem);
state.nested.field = value;
// ❌ Don't use non-immutable array methods
state.items.sort();
state.items.reverse();
```
## Error Handling Patterns
### **Component Error States**
```typescript
// State interface includes error
interface State {
data: DataType[];
loading: boolean;
error: string | null;
}
// View handles error states
view = (state: State) => {
if (state.error) {
return (
<div className="error-state">
<p>Error: {state.error}</p>
<button $onclick="retry-action">Retry</button>
</div>
);
}
// ... rest of view
};
// Update functions handle errors
'load-data': async function* (state: State): AsyncGenerator<State> {
try {
yield { ...state, loading: true, error: null };
const data = await fetchData();
yield { ...state, loading: false, data, error: null };
} catch (error: any) {
yield { ...state, loading: false, error: error.message || 'Unknown error' };
}
}
```
## Best Practices Summary
### **Component Design (Modern Patterns)**
- **Prefer stateful class components** with `mounted` lifecycle for self-contained components
- **Use module-level functions** for state updates to enable easy testing
- **Use $bind for form fields** instead of manual event handlers
- **Use direct function references** in $on directives: `$onclick={[saveFunction]}`
- **Use global events** for parent-child coordination
- Implement guard clauses for loading/error/empty states
- Follow single responsibility principle
### **Event Handling (Updated Rules)**
- **$bind for two-way data binding**: `$bind="formData.fieldName"`
- **$on directives with direct function references**: `$onclick={[functionRef]}`
- **String/tuple actions for legacy patterns**: `$onclick="action-name"` or `$onclick={['action', data]}`
- **Regular properties for DOM manipulation**: `onclick={(e) => e.stopPropagation()}`
- **Global events for coordination**: `app.run('component-saved')`
### **State Management (Enhanced)**
- **Module-level functions** return new state or use async generators
- **$bind automatically handles** form field updates
- **Global events coordinate** between parent and child components
- Always use immutable updates with spread operator
- Include loading, error, and success message states
- Use async generators for progressive updates
- Implement defensive programming with null checks
### **Architecture Patterns**
- **Self-contained components** manage their own form state using `mounted`
- **Parent components** use simple boolean flags for conditional rendering
- **Module-level functions** enable easy unit testing
- **Global events** provide loose coupling between components
- **Success messages** with auto-close functionality
- **Modal patterns** with backdrop click to close
### **Type Safety**
- Define comprehensive state interfaces with success message states
- Use proper TypeScript types for all module-level functions
- Type component props correctly for mounted pattern
- Provide default values for optional props
This guide covers modern AppRun patterns with emphasis on stateful components, module-level functions, $bind for forms, and global event coordination.