I Need Guidance on State Management in Aurelia

Hey folks!

I Just getting my feet wet with Aurelia, and I’m loving its power and adaptability so far. However, I’m at a roadblock when it comes to implementing state management in my app. I’m not sure what the best course of action is.

I’ve been combing through the Aurelia docs and tutorials, but I still have a few burning questions:

  • What are the golden rules for handling state in Aurelia applications?
  • Are there any libraries or design patterns that are especially good fits for Aurelia?
  • How does Aurelia’s approach to state management stack up against other frameworks like React or Vue in terms of ease of use and performance?
  • Any tips, resources, or code examples you could share would be amazing. I’m especially curious about real-world experiences and any hidden dangers to watch out for.
    I also check this new version article: https://discourse.aurelia.io/t/announcing-the-aurelia-2-betaruby

Thanks a ton in advance for your help!

Just use the Aurelia store plugin.

In your ViewModel, make sure to subscribe to the state and then set the local properties to the data from the store. You bind the local ones to the view and you can then update the store from the ViewModel as you please.

I agree with @mario.stopfer . The Aurelia store plug in works really well & is well designed with little need for ceremony, boilerplate or magic strings. The documentation is decent and there are some simple examples out there.

The principals I follow are:

  • only one store & state. Using different states is like using different databases to store parts of the same application. Sooner or later you will need one part to access another part.
    • you can nest the state in as big a hierarchy as you like. Just have a root state object.
  • all changes to the app state happen in actions. Any time I start putting some kind of state information in a view-model I end up in a pickle and regret it. For example, if I dispatch and action that calls some api then the full information about what happened will end up in the state. Did it succeed or not, any errors, any data… all go in the state. Don’t try and put some of this ‘information’ in the view-model and some in the state.
  • I just shallow copy the root state object and change the bits I need to. See example action below. Various libraries out there will do this also.
    • Personally I think this is a pragmatic choice for performance and it has not been a problem. I trust the developer to not muck it up most of the time. No need to deep copy a huge state to update a little data. This is an ideological hell hole though with lots of straw men on fire.
  • data being edited on a view is first cloned to another object on the view-model and then two-way binding does it’s thing. The state is then updated with a ‘save’ button that takes the form data and dispatches an action with the updated information.
  • I avoid rxjs pipelines generally. I find they get too hard to read. Haven’t really needed to do anything fancy, only get a part of the state when it changes.
  • If it seems to be creating a confusing mess then I’m over complicating it. Re-consider what the information actually is involved in the view. eg) if editing a person record then the information may also need to include the stage the current edit process, has a duplicate check on the server been done, the original server data, error messages.

Other things.

  • I ignore the whole ‘time-travel’ argument about app states. In any connected app there is almost always an element of the total information state that is persisted somewhere else on a server.

Works for Me ™

Here’s an example of a state in one of my apps. I’ve cut some out for brevity. This needs a bit of tidying in name and structure but hopefully it helps as a real world example that isn’t a ‘Hello World’.

  • The state contains nested objects that hold the state for different parts of the application like the ‘candidateSearch’ data. There is an action in my app that is pulling paged data from a server and updates this bit of the state. Note that the ‘search’ terms and ordering directive lives principally in the state although it is reflected in a search box on a view. If the search term only lives in the view then the system will get in a pickle.
  • Another nested state example is the ‘candidateState’ object. I’ve included the class for this below also. This is related to a giant multi tab page that has lots of details about candidates (people) in this app.
  • I do store some local settings for layout and other user preferences. These are at the end of the state file. I use a ‘made my own’ modified version of the local state persistence plugin for this. The reset of the state will vapourise if the browser refreshes. I’m ok with that for this app.
  • Other little lists or ‘reference data’ are also in the state. The ‘titles’ and ‘states (states of a country)’. These two are updated by actions but only when the app starts because these rarely change.
  • I use the ‘EntityState’ class to contain a thing being edited. This lets me keep an unmodified copy, collect errors and record saving success.
  • Just smash it out. The renaming and refactoring tools in VS code make it much easier to change the shape of the state as you go along and learn about the domain space. I think my structure is a bit flat and will need more nesting as the app gets bigger to clarify the different parts of the app via function and business domain. That’s ok. I’ll do that when it needs to be done and it’s clearer to me the path forward.

export class AppState {

    /** Currently logged in user */
    currentUser: UserDto;

    /** results and filtering for the candidate search */
    candidateSearch: CandidateSearch = {
        skip: 0,
        top: 20,
        search: undefined,
        orderBy: undefined,
        candidates: [],
        isEndOfResults: false,
        status: 'Current'
    }

    /** currently displayed candidate */
    candidate: CandidateState = new CandidateState();

    /** currently displayed partner */
    client: ClientState = new ClientState();

    /** Booking entries shown on the search screen */
    bookingEntrySearch: BookingEntrySearch = {
        filter: {
            dateFrom: undefined,
            dateTo: undefined,
            filterMode: undefined
        },
        pagedData: new PagedGridData<BookingEntry>(),
        rowIndex: 0
    };

    /** Available team members for filling and creating bookings */
    availableTeamMemberSearch: AvailableTeamMemberSearch = {
        filter: {
            startDate: undefined,
            endDate: undefined,
            clientId: undefined,
            subjectId: undefined,
            countryJobs: false,
            withinTravelTime: false,
            availableOnly: false
        },
        pagedData: new PagedGridData<AvailableTeamMember>()
    };

    /** currently edited booking */
    bookingEdit: EntityState<BookingEntry> = undefined;

    /** titles (mr, mrs, dr...) */
    titles: Title[] = [];

    /** states of Australia (WA, SA, NSW ...) */
    states: State[] = [];

    /** values for a subject lookup / auto-complete */
    subjectLookup: Lookup<Subject> = {
        search: '',
        items: []
    };

    /** values for a client lookup / auto-complete */
    clientLookup: Lookup<ClientProfile> = {
        search: '',
        items: []
    };

    /** temp files uploaded to the server. The key is the key provided when the file is supplied */
    tempFiles: Map<string, { id: number, name: string }> = new Map();

    /** Local settings stored in the browser's local storage. Principally for display preferences */
    localSettings: LocalSettings = {
        candidateSearch: {
            orderBy: undefined
        },
        candidateBookingsLayout: undefined,
        candidatePhoneLogLayout: undefined,
        bookingEntriesLayout: undefined,
        bookingEntriesDeletedLayout: undefined,
        bookingEditLayout: undefined,
        bookingFillLayout: undefined,
        bookingTeamMembersListLayout: undefined
    };
}

/** State for an entity being edited */
export class EntityState<T> {

    constructor(entity?: T) {
        if (entity !== undefined) {
            this.current = cloneDeep(entity);
            this.original = cloneDeep(entity);
        }
    }

    current: T = undefined;
    original: T = undefined;
    isSaved: boolean = true;
    errors: ValidationError[] = [];
};

/** State for the candidate being viewed/edited */
export class CandidateState {
    id: number = undefined;
    profile = new EntityState<CandidateProfile>();
    availability = new EntityState<CandidateAvailability>();
    details = new EntityState<CandidateDetails>();
    certifications: CandidateCertification[] = [];
    certification = new EntityState<CandidateCertificationModel>({
        Definition: {},
    });
    resume = new EntityState<CandidateResume>();
    photo = new EntityState<FileDto>;
    bookings = new PagedGridData<BookingEntry>();
    phoneLog = new PagedGridData<PhoneLogEntry>();
}

Getting state into a view. I use the connectTo decorator.
This seems to work like magic. Here is the top bit of a view-model:


@connectTo<AppState>({
  selector: {
    stateResume: pickChange(s => s.candidate.resume),
    stateCandidate: pickChange(s => s.candidate),
    stateTempFiles: pickChange(s => s.tempFiles)
  }
})
@autoinject()
export class Resume extends Page {


  /** main binding model for the candidate resume information */
  public model: CandidateResume;

  // Data picked from the state when it changes byref
  public stateResume: EntityState<CandidateResume>;
  public stateCandidate: CandidateState;
  public stateTempFiles: Map<string, { id: number, name: string }> = new Map();

// ......

}

pickChange is my little hack to avoid thinking

  export function pickChange<T, R>(project: (value: T, index: number) => R): (store: Store<T>) => Observable<R>{
    return store => store.state.pipe(map(project), distinctUntilChanged());
  }

Finally an Action

This action updates a ‘client’ in our system.

  • first a lazy shallow copy of the whole state.
  • a deep copy of the relevant parts of the state. Trying to avoid any chance of mucking up the original incoming state object.
  • update the new state and update the data via an api. Any validation or other errors get shoved back in the state and surfaced on the view. The view will get this newly modified state. If the user had some strange error like a duplicate of the client name then they will see the value they entered as it was copied to the new state and also any error messages.
/**
 * Updates the client to the remote store
 * @param state 
 * @returns 
 */
export async function updateClientAction(state: AppState, data: ClientProfile) {

    // clone the state
    const newState = Object.assign({}, state);

    let clientProfileApi = Container.instance.get(ClientProfileApi);
    try {

        // Update the state - these values are needed before saving
        // as they may need to be shown to the user again to correct errors
        
        // create a new object reference for. This indicates to subscriptions that the
        // object has changed
        newState.client.profile = cloneDeep(newState.client.profile);

        // update the relevant data
        Object.assign(newState.client.profile.current, data);

        // Clear any errors 
        newState.client.profile.errors = [];

        // If the object has not changed then no need to call the server
        if (!areTheSame(newState.client.profile.current, newState.client.profile.original)) {

            // save the data
            const result = await clientProfileApi.clientProfilePost({ data })

            // update the profile in the state
            newState.client.profile = new EntityState(result);

            // update the client in the search list if present
            const index = newState.clientSearch.data.findIndex(x=>x.ClientId === result.ClientId);
            if(index >= 0){
                newState.clientSearch.data[index] = cloneDeep(result);
            }

            // update in the lookup list if present
            const index2 = newState.clientLookup.items.findIndex(x=>x.ClientId === result.ClientId);
            if(index2 >= 0){
                newState.clientLookup.items[index2] = cloneDeep(result);
            }
        }
        newState.client.profile.isSaved = true;

    } catch (error) {

        // failed to save
        newState.client.profile.isSaved = false;

        // get validation errors from the server if any
        newState.client.profile.errors = parseValidationErrors(error);

        // show the user there has been an error
        handleError(error);
    }


    return newState;
}

I have a monster ‘registerActions’ method that gets called from the constructor of my main app-shell component.
However, check out this one [aurelia-store] how we use it


/* register all actions for the application */
export function registerActions(store: Store<AppState>) {
      // Authentication
      store.registerAction(loginBeginCookieAction.name, loginBeginCookieAction);
      store.registerAction(loginCompleteCookieAction.name, loginCompleteCookieAction);
      store.registerAction(loginBeginOauth2Action.name, loginBeginOauth2Action);
      store.registerAction(loginCompleteOauth2Action.name, loginCompleteOauth2Action);
      store.registerAction(logoutCookieAction.name, logoutCookieAction);
      store.registerAction(logoutOauth2Action.name, logoutOauth2Action);

      // Team members list
      store.registerAction(getCandidatesAction.name, getCandidatesAction);
      // Team member profile
      store.registerAction(getCandidateAction.name, getCandidateAction);
      store.registerAction(updateCandidateAction.name, updateCandidateAction);

     // ..
}
3 Likes

Please note in v2, we are promoting a replacement for the store plugin named state plugin. You can find the doc at https://docs.aurelia.io/aurelia-packages/state

It’s not too different with v1 store