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:
interface FormField<T> { value: T; error: string | null; touched: boolean; } interface RegistrationForm { email: FormField<string>; password: FormField<string>; confirmPassword: FormField<string>; agreedToTerms: FormField<boolean>; } interface AppState { registrationForm: { fields: RegistrationForm; submitting: boolean; submitError: string | null; }; } const initialState: AppState = { registrationForm: { fields: { email: { value: '', error: null, touched: false }, password: { value: '', error: null, touched: false }, confirmPassword: { value: '', error: null, touched: false }, agreedToTerms: { value: false, error: null, touched: false } }, submitting: false, submitError: null } };Create field update and validation actions:
type FieldName = keyof RegistrationForm; // Update field value export const updateField = ( state: AppState, payload: { field: FieldName; value: any } ) => ({ ...state, registrationForm: { ...state.registrationForm, fields: { ...state.registrationForm.fields, [payload.field]: { ...state.registrationForm.fields[payload.field], value: payload.value, touched: true } } } }); // Validate single field export const validateField = ( state: AppState, field: FieldName ): AppState => { const fields = state.registrationForm.fields; let error: string | null = null; switch (field) { case 'email': if (!fields.email.value) { error = 'Email is required'; } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(fields.email.value)) { error = 'Email is invalid'; } break; case 'password': if (!fields.password.value) { error = 'Password is required'; } else if (fields.password.value.length < 8) { error = 'Password must be at least 8 characters'; } break; case 'confirmPassword': if (fields.confirmPassword.value !== fields.password.value) { error = 'Passwords must match'; } break; case 'agreedToTerms': if (!fields.agreedToTerms.value) { error = 'You must agree to the terms'; } break; } return { ...state, registrationForm: { ...state.registrationForm, fields: { ...fields, [field]: { ...fields[field], error } } } }; }; // Validate all fields export const validateAllFields = (state: AppState): AppState => { let newState = state; const fields: FieldName[] = ['email', 'password', 'confirmPassword', 'agreedToTerms']; for (const field of fields) { newState = validateField(newState, field); } return newState; }; // Submit form export async function submitRegistration(state: AppState): Promise<AppState> { // Validate all fields first let newState = validateAllFields(state); // Check if any errors const hasErrors = Object.values(newState.registrationForm.fields) .some(field => field.error !== null); if (hasErrors) { return newState; } // Set submitting newState = { ...newState, registrationForm: { ...newState.registrationForm, submitting: true, submitError: null } }; try { const http = resolve(IHttpClient); const response = await http.fetch('/api/register', { method: 'POST', body: JSON.stringify({ email: newState.registrationForm.fields.email.value, password: newState.registrationForm.fields.password.value }) }); if (!response.ok) { throw new Error('Registration failed'); } // Success - could navigate or reset form return { ...newState, registrationForm: { ...initialState.registrationForm, submitting: false } }; } catch (error) { return { ...newState, registrationForm: { ...newState.registrationForm, submitting: false, submitError: error.message } }; } }Use in component with reactive bindings:
import { IStore } from '@aurelia/state'; import { resolve } from '@aurelia/kernel'; export class RegistrationForm { private store = resolve(IStore<AppState>); get email() { return this.store.getState().registrationForm.fields.email; } get password() { return this.store.getState().registrationForm.fields.password; } get confirmPassword() { return this.store.getState().registrationForm.fields.confirmPassword; } get agreedToTerms() { return this.store.getState().registrationForm.fields.agreedToTerms; } get submitting() { return this.store.getState().registrationForm.submitting; } get submitError() { return this.store.getState().registrationForm.submitError; } updateEmail(value: string) { this.store.dispatch(updateField, { field: 'email', value }); } updatePassword(value: string) { this.store.dispatch(updateField, { field: 'password', value }); } updateConfirmPassword(value: string) { this.store.dispatch(updateField, { field: 'confirmPassword', value }); } updateAgreedToTerms(value: boolean) { this.store.dispatch(updateField, { field: 'agreedToTerms', value }); } validateEmailField() { this.store.dispatch(validateField, 'email'); } validatePasswordField() { this.store.dispatch(validateField, 'password'); } validateConfirmPasswordField() { this.store.dispatch(validateField, 'confirmPassword'); } async submit() { await this.store.dispatch(submitRegistration); } }Template with validation display:
<form submit.trigger="submit()"> <div class="error" if.bind="submitError">${submitError}</div> <div class="form-group"> <label for="email">Email</label> <input id="email" type="email" value.bind="email.value" input.trigger="updateEmail($event.target.value)" blur.trigger="validateEmailField()" class="${email.error ? 'error' : ''}" > <span class="error" if.bind="email.touched && email.error"> ${email.error} </span> </div> <div class="form-group"> <label for="password">Password</label> <input id="password" type="password" value.bind="password.value" input.trigger="updatePassword($event.target.value)" blur.trigger="validatePasswordField()" class="${password.error ? 'error' : ''}" > <span class="error" if.bind="password.touched && password.error"> ${password.error} </span> </div> <div class="form-group"> <label for="confirmPassword">Confirm Password</label> <input id="confirmPassword" type="password" value.bind="confirmPassword.value" input.trigger="updateConfirmPassword($event.target.value)" blur.trigger="validateConfirmPasswordField()" class="${confirmPassword.error ? 'error' : ''}" > <span class="error" if.bind="confirmPassword.touched && confirmPassword.error"> ${confirmPassword.error} </span> </div> <div class="form-group"> <label> <input type="checkbox" checked.bind="agreedToTerms.value" change.trigger="updateAgreedToTerms($event.target.checked)" > I agree to the terms </label> <span class="error" if.bind="agreedToTerms.error"> ${agreedToTerms.error} </span> </div> <button type="submit" disabled.bind="submitting"> ${submitting ? 'Submitting...' : 'Register'} </button> </form>
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:
interface Product { id: string; name: string; price: number; categoryId: string; inStock: boolean; } interface Category { id: string; name: string; } interface AppState { products: Product[]; categories: Category[]; filters: { search: string; categoryId: string | null; showOutOfStock: boolean; sortBy: 'name' | 'price'; }; }Create memoized selectors:
import { createStateMemoizer } from '@aurelia/state'; // Simple selectors (direct state access) const selectProducts = (state: AppState) => state.products; const selectCategories = (state: AppState) => state.categories; const selectFilters = (state: AppState) => state.filters; // Memoized: Filter products by search const selectSearchedProducts = createStateMemoizer( selectProducts, selectFilters, (products, filters) => { if (!filters.search) return products; const search = filters.search.toLowerCase(); return products.filter(p => p.name.toLowerCase().includes(search) ); } ); // Memoized: Filter by category const selectCategoryFilteredProducts = createStateMemoizer( selectSearchedProducts, selectFilters, (products, filters) => { if (!filters.categoryId) return products; return products.filter(p => p.categoryId === filters.categoryId); } ); // Memoized: Filter by stock availability const selectStockFilteredProducts = createStateMemoizer( selectCategoryFilteredProducts, selectFilters, (products, filters) => { if (filters.showOutOfStock) return products; return products.filter(p => p.inStock); } ); // Memoized: Sort products export const selectFilteredProducts = createStateMemoizer( selectStockFilteredProducts, selectFilters, (products, filters) => { const sorted = [...products]; if (filters.sortBy === 'name') { sorted.sort((a, b) => a.name.localeCompare(b.name)); } else { sorted.sort((a, b) => a.price - b.price); } return sorted; } ); // Memoized: Product stats export const selectProductStats = createStateMemoizer( selectFilteredProducts, (products) => ({ total: products.length, inStock: products.filter(p => p.inStock).length, outOfStock: products.filter(p => !p.inStock).length, avgPrice: products.reduce((sum, p) => sum + p.price, 0) / products.length || 0 }) ); // Memoized: Products by category export const selectProductsByCategory = createStateMemoizer( selectProducts, selectCategories, (products, categories) => { return categories.map(category => ({ category, products: products.filter(p => p.categoryId === category.id) })); } );Use selectors in components:
import { IStore } from '@aurelia/state'; import { resolve } from '@aurelia/kernel'; import { observable } from '@aurelia/runtime'; export class ProductList { private store = resolve(IStore<AppState>); @observable filteredProducts: Product[] = []; @observable stats: ReturnType<typeof selectProductStats> = null!; constructor() { // Subscribe to state changes this.store.subscribe(this); } handleStateChange(state: AppState) { // Selectors only recompute if dependencies changed this.filteredProducts = selectFilteredProducts(state); this.stats = selectProductStats(state); } updateSearch(search: string) { this.store.dispatch(updateFilters, { search }); } updateCategory(categoryId: string | null) { this.store.dispatch(updateFilters, { categoryId }); } toggleShowOutOfStock() { const current = this.store.getState().filters.showOutOfStock; this.store.dispatch(updateFilters, { showOutOfStock: !current }); } updateSortBy(sortBy: 'name' | 'price') { this.store.dispatch(updateFilters, { sortBy }); } }Template with filtered data:
<div class="product-list"> <div class="filters"> <input type="text" placeholder="Search..." value.bind="store.getState().filters.search" input.trigger="updateSearch($event.target.value) & debounce:300" > <select value.bind="store.getState().filters.categoryId" change.trigger="updateCategory($event.target.value || null)" > <option value="">All Categories</option> <option repeat.for="cat of store.getState().categories" value="${cat.id}"> ${cat.name} </option> </select> <label> <input type="checkbox" checked.bind="store.getState().filters.showOutOfStock" change.trigger="toggleShowOutOfStock()" > Show out of stock </label> <select value.bind="store.getState().filters.sortBy" change.trigger="updateSortBy($event.target.value)" > <option value="name">Sort by Name</option> <option value="price">Sort by Price</option> </select> </div> <div class="stats"> <span>Total: ${stats.total}</span> <span>In Stock: ${stats.inStock}</span> <span>Average Price: $${stats.avgPrice.toFixed(2)}</span> </div> <div class="products"> <div repeat.for="product of filteredProducts" class="product-card"> <h3>${product.name}</h3> <p class="price">$${product.price}</p> <span class="stock ${product.inStock ? 'in-stock' : 'out-of-stock'}"> ${product.inStock ? 'In Stock' : 'Out of Stock'} </span> </div> </div> </div>
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-helpers.ts import { StateDefaultConfiguration, IStore } from '@aurelia/state'; import { DI } from '@aurelia/kernel'; export function createTestStore<T>( initialState: T, ...handlers: any[] ): IStore<T> { const container = DI.createContainer(); container.register( StateDefaultConfiguration.init(initialState, {}, ...handlers) ); return container.get(IStore) as IStore<T>; } export function getStoreState<T>(store: IStore<T>): T { return store.getState(); }Test synchronous action handlers:
import { describe, it, expect } from '@jest/globals'; import { createTestStore } from './test-helpers'; describe('Counter actions', () => { interface CounterState { count: number; } const increment = (state: CounterState) => ({ ...state, count: state.count + 1 }); const decrement = (state: CounterState) => ({ ...state, count: state.count - 1 }); const incrementBy = (state: CounterState, amount: number) => ({ ...state, count: state.count + amount }); it('should increment count', () => { const store = createTestStore({ count: 0 }, increment); store.dispatch(increment); expect(store.getState().count).toBe(1); }); it('should decrement count', () => { const store = createTestStore({ count: 5 }, decrement); store.dispatch(decrement); expect(store.getState().count).toBe(4); }); it('should increment by amount', () => { const store = createTestStore({ count: 0 }, incrementBy); store.dispatch(incrementBy, 5); expect(store.getState().count).toBe(5); }); it('should handle multiple dispatches', () => { const store = createTestStore({ count: 0 }, increment, decrement); store.dispatch(increment); store.dispatch(increment); store.dispatch(decrement); expect(store.getState().count).toBe(1); }); });Test async action handlers:
import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { IHttpClient } from '@aurelia/fetch-client'; import { Registration } from '@aurelia/kernel'; import { createTestStore } from './test-helpers'; describe('Async user actions', () => { interface User { id: string; name: string; } interface UserState { users: User[]; loading: boolean; error: string | null; } // Action handlers const fetchUsersRequest = (state: UserState) => ({ ...state, loading: true, error: null }); const fetchUsersSuccess = (state: UserState, users: User[]) => ({ ...state, users, loading: false }); const fetchUsersFailure = (state: UserState, error: string) => ({ ...state, loading: false, error }); async function fetchUsers(state: UserState): Promise<UserState> { const http = resolve(IHttpClient); let newState = fetchUsersRequest(state); try { const response = await http.fetch('/api/users'); const users = await response.json(); return fetchUsersSuccess(newState, users); } catch (error) { return fetchUsersFailure(newState, error.message); } } // Mock HTTP client class MockHttpClient { mockResponse: any = null; mockError: Error | null = null; async fetch(url: string): Promise<Response> { if (this.mockError) { throw this.mockError; } return { ok: true, json: async () => this.mockResponse } as Response; } } let mockHttp: MockHttpClient; beforeEach(() => { mockHttp = new MockHttpClient(); }); it('should fetch users successfully', async () => { const users = [ { id: '1', name: 'Alice' }, { id: '2', name: 'Bob' } ]; mockHttp.mockResponse = users; const container = DI.createContainer(); container.register(Registration.instance(IHttpClient, mockHttp)); container.register( StateDefaultConfiguration.init( { users: [], loading: false, error: null }, {}, fetchUsersRequest, fetchUsersSuccess, fetchUsersFailure, fetchUsers ) ); const store = container.get(IStore) as IStore<UserState>; // Dispatch and wait await store.dispatch(fetchUsers); const state = store.getState(); expect(state.loading).toBe(false); expect(state.users).toEqual(users); expect(state.error).toBeNull(); }); it('should handle fetch errors', async () => { mockHttp.mockError = new Error('Network error'); const container = DI.createContainer(); container.register(Registration.instance(IHttpClient, mockHttp)); container.register( StateDefaultConfiguration.init( { users: [], loading: false, error: null }, {}, fetchUsersRequest, fetchUsersSuccess, fetchUsersFailure, fetchUsers ) ); const store = container.get(IStore) as IStore<UserState>; await store.dispatch(fetchUsers); const state = store.getState(); expect(state.loading).toBe(false); expect(state.users).toEqual([]); expect(state.error).toBe('Network error'); }); });Test middleware:
import { describe, it, expect, jest } from '@jest/globals'; import { IStateMiddleware, MiddlewarePlacement } from '@aurelia/state'; describe('Middleware', () => { interface TestState { count: number; history: number[]; } const increment = (state: TestState) => ({ ...state, count: state.count + 1 }); it('should execute before middleware', () => { const calls: string[] = []; const beforeMiddleware: IStateMiddleware<TestState> = (state, action) => { calls.push('before'); return state; }; const container = DI.createContainer(); container.register( StateDefaultConfiguration.init( { count: 0, history: [] }, { middlewares: [ { middleware: beforeMiddleware, placement: MiddlewarePlacement.Before } ] }, increment ) ); const store = container.get(IStore) as IStore<TestState>; store.dispatch(increment); expect(calls).toEqual(['before']); expect(store.getState().count).toBe(1); }); it('should execute after middleware', () => { const afterMiddleware: IStateMiddleware<TestState> = (state, action) => { return { ...state, history: [...state.history, state.count] }; }; const container = DI.createContainer(); container.register( StateDefaultConfiguration.init( { count: 0, history: [] }, { middlewares: [ { middleware: afterMiddleware, placement: MiddlewarePlacement.After } ] }, increment ) ); const store = container.get(IStore) as IStore<TestState>; store.dispatch(increment); store.dispatch(increment); const state = store.getState(); expect(state.count).toBe(2); expect(state.history).toEqual([1, 2]); }); it('should block action with false return', () => { const blockingMiddleware: IStateMiddleware<TestState> = (state, action) => { if (state.count >= 5) { return false; // Block } return state; }; const container = DI.createContainer(); container.register( StateDefaultConfiguration.init( { count: 5, history: [] }, { middlewares: [ { middleware: blockingMiddleware, placement: MiddlewarePlacement.Before } ] }, increment ) ); const store = container.get(IStore) as IStore<TestState>; store.dispatch(increment); // Should not increment because middleware blocked it expect(store.getState().count).toBe(5); }); });Test component integration with store:
import { describe, it, expect } from '@jest/globals'; import { createFixture } from '@aurelia/testing'; import { StateDefaultConfiguration, IStore } from '@aurelia/state'; import { Registration } from '@aurelia/kernel'; describe('Component with Store', () => { interface CounterState { count: number; } const increment = (state: CounterState) => ({ ...state, count: state.count + 1 }); it('should update when store changes', async () => { class CounterComponent { private store = resolve(IStore<CounterState>); get count() { return this.store.getState().count; } increment() { this.store.dispatch(increment); } } const initialState = { count: 0 }; const container = DI.createContainer(); container.register( StateDefaultConfiguration.init(initialState, {}, increment) ); const { component, trigger, assertText, platform } = await createFixture .component(CounterComponent) .html` <div> <span id="count">\${count}</span> <button click.trigger="increment()">Increment</button> </div> ` .deps(Registration.instance(DI.Container, container)) .build() .started; // Initial state assertText('#count', '0'); // Click button trigger.click('button'); await platform.taskQueue.yield(); // Verify state updated assertText('#count', '1'); await fixture.stop(true); }); });
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:
// Action type definitions interface IncrementAction { type: 'INCREMENT'; } interface DecrementAction { type: 'DECREMENT'; } interface IncrementByAction { type: 'INCREMENT_BY'; payload: number; } interface SetCountAction { type: 'SET_COUNT'; payload: number; } interface ResetAction { type: 'RESET'; } // Union of all action types type CounterAction = | IncrementAction | DecrementAction | IncrementByAction | SetCountAction | ResetAction; // Action creators with type safety export const CounterActions = { increment: (): IncrementAction => ({ type: 'INCREMENT' }), decrement: (): DecrementAction => ({ type: 'DECREMENT' }), incrementBy: (amount: number): IncrementByAction => ({ type: 'INCREMENT_BY', payload: amount }), setCount: (count: number): SetCountAction => ({ type: 'SET_COUNT', payload: count }), reset: (): ResetAction => ({ type: 'RESET' }) };Create type-safe reducer with exhaustiveness checking:
interface CounterState { count: number; history: number[]; } const initialState: CounterState = { count: 0, history: [] }; // Single reducer handling all actions export const counterReducer = ( state: CounterState, action: CounterAction ): CounterState => { switch (action.type) { case 'INCREMENT': return { ...state, count: state.count + 1, history: [...state.history, state.count + 1] }; case 'DECREMENT': return { ...state, count: state.count - 1, history: [...state.history, state.count - 1] }; case 'INCREMENT_BY': return { ...state, count: state.count + action.payload, history: [...state.history, state.count + action.payload] }; case 'SET_COUNT': return { ...state, count: action.payload, history: [...state.history, action.payload] }; case 'RESET': return initialState; default: // Exhaustiveness check - TypeScript will error if we miss a case const _exhaustive: never = action; return state; } };Register with store and use in components:
import Aurelia from 'aurelia'; import { StateDefaultConfiguration } from '@aurelia/state'; Aurelia .register( StateDefaultConfiguration.init(initialState, {}, counterReducer) ) .app(MyApp) .start();import { IStore } from '@aurelia/state'; import { resolve } from '@aurelia/kernel'; import { CounterActions } from './counter-actions'; export class Counter { private store = resolve(IStore<CounterState>); get count() { return this.store.getState().count; } get history() { return this.store.getState().history; } // Type-safe action dispatching increment() { this.store.dispatch(counterReducer, CounterActions.increment()); } decrement() { this.store.dispatch(counterReducer, CounterActions.decrement()); } incrementBy(amount: number) { this.store.dispatch(counterReducer, CounterActions.incrementBy(amount)); } setCount(count: number) { this.store.dispatch(counterReducer, CounterActions.setCount(count)); } reset() { this.store.dispatch(counterReducer, CounterActions.reset()); } }Template with type-safe actions:
<div class="counter"> <h2>Count: ${count}</h2> <div class="buttons"> <button click.trigger="decrement()">-</button> <button click.trigger="increment()">+</button> <button click.trigger="incrementBy(5)">+5</button> <button click.trigger="reset()">Reset</button> </div> <div class="history"> <h3>History</h3> <ul> <li repeat.for="value of history">${value}</li> </ul> </div> </div>
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:
interface BatchAction<T> { type: 'BATCH'; actions: Array<(state: T) => T>; } export function createBatchAction<T>( ...actions: Array<(state: T) => T> ): BatchAction<T> { return { type: 'BATCH', actions }; } export function batchReducer<T>(state: T, action: BatchAction<T>): T { if (action.type !== 'BATCH') { return state; } // Apply all actions sequentially return action.actions.reduce((currentState, actionFn) => { return actionFn(currentState); }, state); }Use batch actions in complex operations:
interface TodoState { todos: Todo[]; filter: 'all' | 'active' | 'completed'; selectedIds: string[]; } const addTodo = (state: TodoState, todo: Todo) => ({ ...state, todos: [...state.todos, todo] }); const setFilter = (state: TodoState, filter: TodoState['filter']) => ({ ...state, filter }); const selectTodo = (state: TodoState, id: string) => ({ ...state, selectedIds: [...state.selectedIds, id] }); // Component export class TodoList { private store = resolve(IStore<TodoState>); // Without batching - 3 separate renders addTodoSlowly(title: string) { const newTodo: Todo = { id: crypto.randomUUID(), title, completed: false }; this.store.dispatch(addTodo, newTodo); this.store.dispatch(selectTodo, newTodo.id); this.store.dispatch(setFilter, 'all'); // Component re-renders 3 times! } // With batching - 1 render addTodoFast(title: string) { const newTodo: Todo = { id: crypto.randomUUID(), title, completed: false }; const batchAction = createBatchAction<TodoState>( state => addTodo(state, newTodo), state => selectTodo(state, newTodo.id), state => setFilter(state, 'all') ); this.store.dispatch(batchReducer, batchAction); // Component re-renders only once! } }Create batch middleware for automatic batching:
import { IStateMiddleware, MiddlewarePlacement } from '@aurelia/state'; interface QueuedAction<T> { handler: (state: T, action: any) => T; action: any; } export function createBatchMiddleware<T>( flushDelayMs: number = 0 ): IStateMiddleware<T> { let queue: QueuedAction<T>[] = []; let timeoutId: number | null = null; let currentStore: any = null; const flush = () => { if (queue.length === 0) return; const actionsToProcess = [...queue]; queue = []; // Process all queued actions in one batch const batchedState = actionsToProcess.reduce((state, { handler, action }) => { return handler(state, action); }, currentStore.getState()); // Update state once currentStore['_state'] = batchedState; currentStore['_notify'](); timeoutId = null; }; return (state, action, settings) => { // Skip if this is already a batch operation if (action?.type === 'BATCH') { return state; } // Queue the action queue.push({ handler: settings?.handler || ((s) => s), action }); // Set up flush if (timeoutId !== null) { clearTimeout(timeoutId); } if (flushDelayMs === 0) { // Immediate batching (next microtask) Promise.resolve().then(flush); } else { // Delayed batching timeoutId = window.setTimeout(flush, flushDelayMs); } // Return false to prevent immediate processing return false; }; }
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?