I want to know Best Practices for State Management in Aurelia

Hello All Community Member,

I am working on a project where effective state management is crucial and I want to ensure that I am using the most efficient and maintainable approach.

What are the recommended strategies for state management in Aurelia? Are there any design patterns or architectural principles that work particularly well with Aurelias ecosystem??

There are any third party libraries or plugins that integrate well with Aurelia for state management? :thinking:

Also, I have gone through this resorse/artical; https://discourse.aurelia.io/t/best-practices-for-aurelia-state-managementminitab which definitely helped me out a lot.

My last question is How do you manage complex or deeply nested states in Aurelia applications? Are there specific techniques or tools that you use to keep state management manageable and performant?

Thank you in advance for your help…!!

This is from what I know, it’s likely lacking and improper in different ways, but I’ll just share, in case it helps motivate someone who knows better feel like rectifying and providing a better perspective. I also assume your app will mostly be dealing with not too sophisticated data, so the example can be kept simple.


From my experience, which is limited, I can start by asking myself: “do I want to be able to test my state without running any UI related code?”

  • If the answer is yes, then it’s likely that I’ll group my state related code in a separate area, almost like a library inside my application so that UI will only be calling to do queries/subscribe to changes/request mutation.

    • There’ probably many ways to achieve this, one way is to implement every thing yourselfs, with a central state and methods to do mutation, like the following example

      export class MyCentralState {
        users: User[] = [...]
        updateUser(newDetails: User) {
          // update and refetch to update `users` if necessary
          ...
        }
      }
      

      When the app grows, you’ll have more sections to add to this state, but you can just repeat what was done before. For example, adding company:

      
      export class MyCentralState {
        users: User[] = [...]
        updateUser(newDetails: User) {
          // update and refetch to update `users` if necessary
          ...
        }
      
        companies: Company[] = [...]
        updateCompany(newDetails: Company) {
          // update and refetch to update `companies` if necessary
          ...
        }
      

      Because all mutation of the state happens through a single source, you may have an easier time updating all part of the state correctly when a mutation happens.

      In my opinion, this allows myself to start smalland keep everything simple by grouping everything into a single place. If a file get too big, I can still split the central state into different smaller pieces and only keep the main central state as the call delegator: i.e it’s just a cell that calls other actual implementation. This can be combined with the @aurelia/state plugin too, but that’ll depend on your actual implementation.

    • Another way to implement this is to use something like Graphql & Apollo client. In a graphql based application, mutations and queries go through a client (Apollo) and it will be able to detect the mutation and update its cache automatically, as long as you have an id field in your data model. (I’m not an expert on Grahpql so I may be wrong).

      Doing this way removes the burden of managing state & its growth, since you only need to change the queries in different places to suit your needs. For automatic queries observation, something like ApolloClient.watchQuery can be used to create an observable query to subscribe to, and updating the results in your component accordingly. A simple “hook” to demonstrate this idea:

      import {
        ApolloError,
        DocumentNode,
        gql,
        type ObservableQuery
      } from '@apollo/client/core';
      import { type Subscription } from 'zen-observable-ts';
      import { IContainer, resolve, lifecyclehooks } from 'aurelia';
      
      export function useQuery<T, TVariables = unknown>(
        query: string | DocumentNode,
        variables?: TVariables
      ) {
        query = typeof query === 'string' ? gql(query) : query;
        const container = resolve(IContainer);
        const client = container.get(IGraphqlClient);
      
        const observableQuery = client.watchQuery<T, TVariables>({
          query,
          variables,
          fetchPolicy: 'network-only',
          nextFetchPolicy: 'cache-first',
        });
        let sub: Subscription;
      
        const result: {
          data: T | null;
          error: ApolloError | null;
          start: () => void;
          stop: () => void;
        } = {
          data: null,
          error: null,
          start: () => {
            if (sub != null) return;
            sub = observableQuery.subscribe({
              next: ({ data, error }) => {
                // structuredClone is important
                // apollo automatically defines writable: false
                // aurelia won't be able to observe it properly
                result.data = structuredClone(data);
                result.error = error;
              }
            });
          },
          stop: () => {
            sub?.unsubscribe();
            sub = null;
          }
        };
      
        return result;
      };
      

      And then you can use it like this

      export class UsersCustomElement {
        userResult = useQuery<{ users: $User[] }>(`
          query GetUsers {
            users {
              id
              name
              settings {
                theme
                notifications
                language
                timezone
              }
            }
          }`
        );
      
        binding() {
          this.userResult.start();
        }
      
        unbinding() {
          this.userResult.stop();
        }
      }
      

      Then in the template:

      <div repeat.for="user of userResult.data.users">
        ...
      </div>
      

      Whenever a mutation happens, Apollo client will automatically checks the id of the user being mutated and update our userResult.data correctly.

  • If the answer is no, then I think I may follow one of the two approaches below:

    • One way to start is to have state embedded right in the components that needed and when there’ mutiple components that need to access the same data, you can start moving this data into a global state. This approach allows you to start even smaller and faster, but it’ll likely to be messy just as fast, or maybe a bit slower, depends.

    • Another way is to, again, use something else that deal with state, as I assume your app is a server backed app with user data stored somewhere else.

      The apollo “hook” above can be modified to make it automatically hook into UI component lifecycles, so that you don’t have to add the binding/unbinding pair everywhere this is used, like the following example:

      import {
        ApolloError,
        DocumentNode,
        gql,
        type ObservableQuery
      } from '@apollo/client/core';
      import { type Subscription } from 'zen-observable-ts';
      import { IContainer, lifecyclehooks, resolve } from 'aurelia';
      
      export function useQuery<T, TVariables = unknown>(
        query: string | DocumentNode,
        variables?: TVariables
      ) {
        query = typeof query === 'string' ? gql(query) : query;
        const container = resolve(IContainer);
        const client: ApolloClient<unknown> = resolve(IGraphqlClient);
      
        const result: {
          data: T | null;
          error: ApolloError | null
        } = { data: null, error: null };
      
        let observableQuery: ObservableQuery<T, TVariables>;
        let sub: Subscription;
      
        container.register(
          @lifecyclehooks() class UseQueryHooks {
            binding() {
              observableQuery ??= client.watchQuery<T, TVariables>({
                query,
                variables,
                fetchPolicy: 'network-only',
                nextFetchPolicy: 'cache-first',
              });
              sub = observableQuery.subscribe({
                next: ({ data, error }) => {
                  // structuredClone is important
                  // apollo automatically defines writable: false
                  // aurelia won't be able to observe it properly
                  result.data = structuredClone(data);
                  result.error = error;
                },
              });
            }
      
            unbinding() {
              observableQuery.stopPolling();
              sub.unsubscribe();
            }
          })
        );
      
        return result;
      };
      

      And then you can use it like this

      export class UsersCustomElement {
        userResult = useQuery<{ users: $User[] }>(`
          query GetUsers {
            users {
              id
              name
              settings {
                theme
                notifications
                language
                timezone
              }
            }
          }`
        );
      

      Then in the template:

      <div repeat.for="user of userResult.data.users">
        ...
      </div>
      

Extra: in both of the apollo examples above, the apollo client registration is like this

const client = (() => {
  const authProvider = resolve(IAuthProvider);
  const authLink = setContext(async (_, { headers, env }) => {
    // get the authentication token from local storage if it exists
    const token = await authProvider.getToken();
    // return the headers to the context so httpLink can read them
    return {
      headers: {
        ...headers,
        // some extra headers depends on needs
        ['x-env']: env ?? '',
        authorization: token ? `Bearer ${token}` : "",
      }
    }
  });

  return new ApolloClient({
    link: authLink.concat(new HttpLink({
      uri: '/graphql',
      credentials: 'same-origin'
    })),
    cache: new InMemoryCache(),
  });
})();

Aurelia.register(
  Regisration.instance(IGraphqlClient, client),
  ...
)

I’ve done only simple things, so I’ve only got simple suggestion/usages to share. If it’s of no help, then maybe this will help motivate some more comments.

6 Likes

Throwing my 2c in here as I’ve gone down many paths of state management in Aurelia over the years. While tools like aurelia-store, @aurelia/state (if you’re using Aurelia 2) and Redux are awesome for some things, I’ve seen them overused, and sometimes they just add a bunch of extra work for not much gain.

Honestly, for a lot of projects, a simple singleton class can handle state just fine. You basically create a class that holds your app’s data, and then you can inject it anywhere you need it.

Here’s a quick example:

export class AppState {
  someValue = 'initial value';
  someOtherValue = 0;

  updateSomeValue(newValue) {
    this.someValue = newValue;
  }

  incrementOtherValue() {
    this.someOtherValue++;
  }
}

Super simple, right? You lose things like undo/redo and time-travel debugging, but let’s be real, how often do you actually need those?

If you do want the ability to track state, you can actually write some simple code to do this without additional libs or config:

export class AppState {
  private history: any[] = [];
  private historyIndex: number = -1;
  public someValue: string = 'initial value';

  constructor() {
    this.saveState(); // Save initial state
  }

  updateSomeValue(newValue: string) {
    this.someValue = newValue;
    this.saveState();
  }

  private saveState() {
    // Truncate history if we've undone and then made a new change
    this.history = this.history.slice(0, this.historyIndex + 1);
    this.history.push({ someValue: this.someValue });
    this.historyIndex++;
  }

  canUndo() {
    return this.historyIndex > 0;
  }

  undo() {
    if (this.canUndo()) {
      this.historyIndex--;
      this.someValue = this.history[this.historyIndex].someValue;
    }
  }

  canRedo() {
    return this.historyIndex < this.history.length - 1;
  }

  redo() {
    if (this.canRedo()) {
      this.historyIndex++;
      this.someValue = this.history[this.historyIndex].someValue;
    }
  }
}

To use it, you’d just inject it:

import { inject } from 'aurelia-framework';
import { AppState } from './app-state';

@inject(AppState)
export class MyComponent {
  constructor(private appState: AppState) {}

  updateValue() {
    this.appState.updateSomeValue('new value!');
  }

  undo() {
    this.appState.undo();
  }

  redo() {
    this.appState.redo();
  }

  get canUndo() {
      return this.appState.canUndo();
  }

  get canRedo() {
      return this.appState.canRedo();
  }
}

This only tracks one property, but it shows you how to implement these things inside a singleton. If you need advanced state tracking, you could throw in RxJS to make it a bit nicer. The aurelia-store plugin in v1 uses RxJS and a BehaviorSubject for state. So at that point you’d probably be better off using an Aurelia lib instead of rolling your own.

One HUGE thing: don’t use state management for temporary stuff like form data or UI state. Trust me, it’s a nightmare. Aurelia’s built-in data binding is perfect for that kind of thing.

I worked on a big Aurelia project where we went all-in on state management, and it ended up being a huge pain. We spent so much time just dealing with the extra complexity. We eventually ripped it out, and the codebase became way easier to manage. We switched to singletons (one for each data type) and then leveraged localStorage for caching, getters/setters for controlling access to the data and how it’s read and written.

So, before you jump into a full-on state management solution, think about if you really need it. Start simple, and only add the complexity if you absolutely have to. You might be surprised how far a simple singleton can take you.

3 Likes