Outcome Recipes
Advanced Store patterns for async workflows, testing, form management, selectors, and type-safe state management with @aurelia/state.
These recipes complement the State Outcome Recipes by focusing on developer ergonomics, async workflows, testing, and type safety. Use them when building production applications with @aurelia/state.
1. Async action workflows with loading states
Goal: Handle async operations (API calls) with proper loading, success, and error states using a consistent pattern.
Steps
Define state shape with loading/error tracking:
interface User { id: string; name: string; email: string; } interface AppState { users: { data: User[]; loading: boolean; error: string | null; }; currentUser: { data: User | null; loading: boolean; error: string | null; }; } const initialState: AppState = { users: { data: [], loading: false, error: null }, currentUser: { data: null, loading: false, error: null } };Create action handlers for request/success/failure pattern:
import { IHttpClient } from '@aurelia/fetch-client'; import { resolve } from '@aurelia/kernel'; // Request action - sets loading state export const fetchUsersRequest = (state: AppState) => ({ ...state, users: { ...state.users, loading: true, error: null } }); // Success action - stores data export const fetchUsersSuccess = (state: AppState, users: User[]) => ({ ...state, users: { data: users, loading: false, error: null } }); // Failure action - stores error export const fetchUsersFailure = (state: AppState, error: string) => ({ ...state, users: { ...state.users, loading: false, error } }); // Async thunk - orchestrates the flow export async function fetchUsers(state: AppState): Promise<AppState> { const http = resolve(IHttpClient); // Start loading let newState = fetchUsersRequest(state); try { const response = await http.fetch('/api/users'); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const users = await response.json(); // Success return fetchUsersSuccess(newState, users); } catch (error) { // Failure return fetchUsersFailure(newState, error.message); } }Register handlers and use in components:
import Aurelia from 'aurelia'; import { StateDefaultConfiguration } from '@aurelia/state'; Aurelia .register( StateDefaultConfiguration.init( initialState, {}, fetchUsersRequest, fetchUsersSuccess, fetchUsersFailure, fetchUsers ) ) .app(MyApp) .start();import { IStore } from '@aurelia/state'; import { resolve } from '@aurelia/kernel'; import { fromState } from '@aurelia/state'; export class UserList { private store = resolve(IStore<AppState>); @fromState(s => s.users.data) users: User[]; @fromState(s => s.users.loading) loading: boolean; @fromState(s => s.users.error) error: string | null; async attached() { await this.store.dispatch(fetchUsers); } async refresh() { await this.store.dispatch(fetchUsers); } }Display loading/error states in template:
<div class="user-list"> <div if.bind="loading" class="loading"> <span>Loading users...</span> </div> <div if.bind="error" class="error"> <p>${error}</p> <button click.trigger="refresh()">Retry</button> </div> <div if.bind="!loading && !error"> <div repeat.for="user of users" class="user-card"> <h3>${user.name}</h3> <p>${user.email}</p> </div> <div if.bind="users.length === 0" class="empty"> No users found. </div> </div> </div>
Checklist
Loading state shows during API call
Success updates data and clears loading/error
Failure shows error message with retry option
Component uses
@fromStatefor reactive updatesAsync action handlers return Promise
State transitions are tracked (request → success/failure)
2. Form state management with validation
Goal: Manage complex form state including field values, validation errors, touched fields, and submission status.
Steps
Define form state structure:
Create field update and validation actions:
Use in component with reactive bindings:
Template with validation display:
Checklist
Field values stored in state
Validation runs on blur
Touched fields tracked to prevent premature errors
Submit validates all fields
Submission state prevents double-submit
Form can be reset after successful submission
Server errors displayed separately from validation errors
3. Memoized selectors for performance
Goal: Create efficient computed values from state that only recalculate when dependencies change, preventing unnecessary re-renders.
Steps
Define state with complex relationships:
Create memoized selectors:
Use selectors in components:
Template with filtered data:
Checklist
Selectors only recompute when dependencies change
Multiple selectors can compose (chain) together
Component subscribes to store and updates local properties
Template shows derived data without inline computation
Filtering/sorting happens in selectors, not component
Stats calculated once per state change, not per render
4. Testing store actions and middleware
Goal: Write comprehensive tests for action handlers, async operations, and middleware in isolation and integration.
Steps
Set up test environment with mock store:
Test synchronous action handlers:
Test async action handlers:
Test middleware:
Test component integration with store:
Checklist
Action handlers tested in isolation
Async actions tested with mock HTTP client
Middleware execution order verified
Middleware can transform or block actions
Component integration with store is testable
Test helpers simplify store creation
All state transitions are predictable and tested
5. Type-safe actions with discriminated unions
Goal: Create fully type-safe store actions using TypeScript discriminated unions to prevent typos and improve IDE autocomplete.
Steps
Define action types as discriminated union:
Create type-safe reducer with exhaustiveness checking:
Register with store and use in components:
Template with type-safe actions:
Checklist
Action types are discriminated unions
Action creators provide type safety
Reducer uses switch with exhaustiveness check
TypeScript catches missing action types
IDE provides autocomplete for action types
Payload types are enforced
No string literals in component code
6. Batch updates to prevent intermediate renders
Goal: Dispatch multiple actions as a single transaction to avoid intermediate state emissions and unnecessary re-renders.
Steps
Create batch action wrapper:
Use batch actions in complex operations:
Create batch middleware for automatic batching:
Checklist
Batch action combines multiple updates
Only one state emission for batched actions
Components re-render once instead of N times
Batch middleware can auto-batch within time window
Complex operations remain atomic
State consistency maintained throughout batch
Store pattern cheat sheet
Async workflows
Async action handlers + loading state
Making API calls, handling loading/error states
Form management
Nested state with validation
Complex forms with validation logic
Memoized selectors
createStateMemoizer
Deriving computed values, optimizing performance
Testing
Test helpers + mocks
Writing unit/integration tests for store
Type-safe actions
Discriminated unions
Preventing typos, getting IDE autocomplete
Batch updates
Batch reducer or middleware
Preventing intermediate renders, atomic updates
Best practices
Normalize state structure: Flat objects with IDs, not nested arrays
Use selectors for derived data: Don't compute in components or templates
Keep actions pure: No side effects in reducers
Handle async in actions, not middleware: Middleware for cross-cutting concerns
Test action handlers separately: Unit test pure functions first
Type everything: Use TypeScript for state shape and actions
Memoize expensive computations: Use
createStateMemoizerfor derived stateBatch related updates: Use batch actions for complex operations
Structure by feature: Group related actions/state together
Subscribe in constructors, unsubscribe in dispose: Prevent memory leaks
See also
State plugin guide - Complete state API reference
State outcome recipes - Persistence & sync patterns
Store configuration - Plugin setup
Store middleware - Middleware deep dive
Testing outcome recipes - Component testing patterns
Last updated
Was this helpful?