(Originally published on Medium.com on Jan 3rd, 2025)

Recently, I got to explore the codebase in Grafana’s open-source repository, I was inspired to create two custom decorators that significantly streamlined my workflow. Here’s my journey and how you can leverage these ideas too.

Why MobX Decorators?

Decorators in MobX provide a declarative way to define state and its reactive behavior. By wrapping properties, methods, or classes with decorators like @observable, @action, and @computed, you can create a clean and concise setup for state management. This approach not only improves code readability but also aligns perfectly with modern JavaScript and TypeScript.

Grafana’s Influence

Grafana’s open-source codebase showcases advanced patterns for state management and architecture. While diving into their implementation, I noticed how decorators were used effectively to enhance reusability and clarity. Inspired by this, I decided to experiment with creating similar MobX decorators to address specific challenges in my projects.

The Custom Decorators

1. @AutoLoadingState

This decorator manages a loading state for asynchronous actions, ensuring that UI components reflect the correct loading status.

export const AutoLoadingState = (actionKey: string) => {
const ff = function (_target: object, _key: string, descriptor: PropertyDescriptor) {
let nbOfPendingActions = 0;
const originalFunction = descriptor.value;
descriptor.value = async function (...args: any) {
LoaderStore.setLoadingAction(actionKey, true);
nbOfPendingActions++;
try {
return await originalFunction.apply(this, args);
} finally {
nbOfPendingActions--;
if (nbOfPendingActions === 0) {
LoaderStore.setLoadingAction(actionKey, false);
}
}
};
};
return ff as any;
};

Use Case: Automatically manage loading states for actions, reducing boilerplate code for updating and resetting loaders.


2. @WithGlobalNotification

This decorator wraps actions with global notifications for success and failure states, enhancing user feedback.

export function WithGlobalNotification({ success, failure, composeFailureMessageFn, failureType = 'error' }: GlobalNotificationConfig) {
const ff = function (_target: object, _key: string, descriptor: PropertyDescriptor) {
const childFunction = descriptor.value;
descriptor.value = async function (...args: any) {
try {
const response = await childFunction.apply(this, args);
success && openNotification(success);
return response;
} catch (err) {
const open = failureType === 'error' ? openErrorNotification : openWarningNotification;
const message = composeFailureMessageFn ? await composeFailureMessageFn(err as Response) : failure;
message && open(message);
}
};
};
return ff as any;
}

Use Case: Provide consistent user notifications for success or error cases in actions, centralizing logic for better maintainability.


3. @DebouncedAction

This decorator ensures that an action is executed only after a specified delay, preventing excessive calls during rapid state changes.

function DebouncedAction(delay: number) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
let timeout: NodeJS.Timeout;
descriptor.value = function (...args: any[]) {
clearTimeout(timeout);
timeout = setTimeout(() => {
originalMethod.apply(this, args);
}, delay);
};
return descriptor;
};
}

class Store {
@DebouncedAction(300)
updateSearchQuery(query: string) {
console.log(`Search query updated to: ${query}`);
}
}

Use Case: Ideal for scenarios like search input where you want to delay state updates to avoid unnecessary processing.


4. @LoggedAction

This decorator logs every action invocation with its arguments and the resulting state.

function LoggedAction(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Action: ${propertyKey}, Args:`, args);
const result = originalMethod.apply(this, args);
console.log(`Resulting state:`, this);
return result;
};
return descriptor;
}

class Store {
@LoggedAction
addItem(item: string) {
console.log(`Adding item: ${item}`);
}
}

Use Case: Perfect for debugging and understanding how actions impact the state during development.


Taking the @AutoLoadingState decorator as the main example:

To support it, I also implemented a LoaderStore which is responsible to save a list of all actions that is in a loading state right now.

import { action, observable, makeObservable } from 'mobx';

class LoaderStoreClass {
@observable
items: {
[key: string]: boolean;
} = {};
constructor() {
makeObservable(this);
}
@action.bound
setLoadingAction(actionKey: string | string[], isLoading: boolean) {
if (Array.isArray(actionKey)) {
actionKey.forEach((key) => {
this.items[key] = isLoading;
});
} else {
this.items[actionKey] = isLoading;
}
}
isLoading(actionKey: string): boolean {
return !!this.items[actionKey];
}
get loadingStates() {
return this.items;
}
}

export const LoaderStore = new LoaderStoreClass();

And a LoaderHelper Class to help checking whether a certain action is being loaded or not. It accepts either a single action key or multiple action keys ( Examples attached below):

import { LoaderStore } from './loader';

export class LoaderHelper {
static isLoading(store: typeof LoaderStore, actionKey: string | string[]) {
return typeof actionKey === 'string' ? store.items[actionKey] : actionKey.some((key) => store.items[key]);
}
}

And a list of ActionKeys to differentiate loading indicators:

export enum ActionKey {
GET_DASHBOARD_DATA = 'GET_DASHBOARD_DATA',
}

And a hook to check for ActionKeys that is being loaded:

import { useStores } from 'index';
import { ActionKey } from 'models/loader/action-keys';
import { LoaderHelper } from 'models/loader/loader.helpers';

export const useIsLoading = (actionKey: ActionKey | ActionKey[]) => {
const { loaderStore } = useStores();
return LoaderHelper.isLoading(loaderStore, actionKey);
};

export const getAllLoadingStates = (loaderStore) => {
return loaderStore.loadingStates;
};

And I use it like that in React Components:

const isGeneratingPost = useIsLoading([ActionKey.GENERATE_POST, ActionKey.REGENERATE_POST]);
const isLoadingPosts = useIsLoading(ActionKey.GET_POSTS);
const isUpdatingPost = useIsLoading(ActionKey.UPDATE_POST);

And I use the Loading decorator in the Mobx store as below:

 @AutoLoadingState(ActionKey.GET_POSTS)
async getPostsAsync() {
const response = await this._postsService.getPostsAsync();
if (response && response.data && !response?.isError) this.setPosts(response.data);
return response;
}

Final Thoughts

Exploring MobX decorators and creating custom ones has opened up new possibilities for managing state elegantly. Inspired by Grafana’s exemplary use of patterns, I’ve found that these decorators not only simplify my code but also make it more intuitive and much more maintainable.

If you’re already using MobX, give these decorators a try. And if you’re new to MobX, now is a great time to dive in and see how decorators can elevate your projects.

Happy coding!

References:

Write A Comment