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

  1. 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 }
    };
  2. 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);
      }
    }
  3. 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);
      }
    }
  4. 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 @fromState for reactive updates

  • Async 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

  1. Define form state structure:

  2. Create field update and validation actions:

  3. Use in component with reactive bindings:

  4. 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

  1. Define state with complex relationships:

  2. Create memoized selectors:

  3. Use selectors in components:

  4. 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

  1. Set up test environment with mock store:

  2. Test synchronous action handlers:

  3. Test async action handlers:

  4. Test middleware:

  5. 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

  1. Define action types as discriminated union:

  2. Create type-safe reducer with exhaustiveness checking:

  3. Register with store and use in components:

  4. 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

  1. Create batch action wrapper:

  2. Use batch actions in complex operations:

  3. 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

Pattern
Key API
Use When

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

  1. Normalize state structure: Flat objects with IDs, not nested arrays

  2. Use selectors for derived data: Don't compute in components or templates

  3. Keep actions pure: No side effects in reducers

  4. Handle async in actions, not middleware: Middleware for cross-cutting concerns

  5. Test action handlers separately: Unit test pure functions first

  6. Type everything: Use TypeScript for state shape and actions

  7. Memoize expensive computations: Use createStateMemoizer for derived state

  8. Batch related updates: Use batch actions for complex operations

  9. Structure by feature: Group related actions/state together

  10. Subscribe in constructors, unsubscribe in dispose: Prevent memory leaks

See also

Last updated

Was this helpful?