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?
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