DEV Community

Óscar Pérez
Óscar Pérez

Posted on

Frontend state management with clean architecture

  1. ## INTRODUCTION

One of the most critical aspects of developing a client-side application is how we handle global state management.

Many developers spend hours and hours searching for the perfect tool: Redux, Akita, Pinia, Ngrx, Context, Vuex... But the truth is, if we aim for code that is agnostic and truly maintainable over time, we cannot simply delegate the core piece of our applications to external libraries or tools whose future maintenance is uncertain.

That's where clean architecture comes to the rescue, advising us to separate the domain or core components of our application from those parts that are likely to change over time. In other words, tools and frameworks should never directly manage any piece of the global state of our application.

So, I've taken the initiative to create this post and share my step-by-step solution on how to handle global domain state in a client-side application using Typescript and Object-Oriented Programming (OOP).

Let's begin! 🚀

REACTIVITY

When it comes to managing global state, we need it to be reactive to any changes that occur within it. Therefore, if we are going to build a global state handler, reactivity should be our top priority.

  1. Let's create an abstract class that will solely take care of managing the global state with reactivity:
export abstract class UseCase<S> {
    public state: S;
    private listeners: Subscription<S>[] = [];
}
Enter fullscreen mode Exit fullscreen mode

We will name the class UseCase, and it will have two properties: state and listeners.

state will be responsible for holding the global state, while listeners will handle reactivity.

  1. Our class will include a constructor that receives the state of the feature extending the use case:
    constructor(initialState: S) {
        this.state = initialState;
    }
Enter fullscreen mode Exit fullscreen mode
  1. Methods:

The first method of this class will be the one that allows us to execute any use case:

abstract execute(value?: any): void
Enter fullscreen mode Exit fullscreen mode

Absolutely right! The first method should be abstract so that the extending class can handle any logic, such as calling an API to fetch information.

On the other hand, we also need a method to retrieve the state when required:

    getState(): S {
        return this.state;
    }
Enter fullscreen mode Exit fullscreen mode

Exactly! We need another method to update the state. This will allow us to modify the global state when necessary:

    updateState(newState: Partial<S>): void {
        this.state = {
            ...this.state,
            ...newState,
        };
    }
Enter fullscreen mode Exit fullscreen mode

Now, here comes the magic where reactivity occurs intuitively and easy to understand.

We'll add a small piece of code to the updateState method that will execute any method found in the listeners property we created earlier.

Here's how the updateState method would look like:

    updateState(newState: Partial<S>): void {
        this.state = {
            ...this.state,
            ...newState,
        };

        if (this.listeners.length) {
            this.listeners.forEach((listener) => listener(this.state));
        }
    }
Enter fullscreen mode Exit fullscreen mode

Now, every time we want to update the state, it will trigger a function to which we'll pass the updated state as an argument.

This will enable reactivity as any listeners registered with the listeners property will be notified and can react accordingly to the changes in the global state. This approach simplifies the handling of reactivity and makes it intuitive and easy to manage:

listener(this.state)
Enter fullscreen mode Exit fullscreen mode

To make the listeners property functional, we need to introduce the methods we want it to execute. We'll achieve this by adding another method called subscribe.

   subscribe(listener: Subscription<S>) {
        this.listeners.push(listener);
    }
Enter fullscreen mode Exit fullscreen mode

The subscribe method will allow us to add methods to the listeners property so that they can be executed when the state is updated. This way, we can dynamically register functions to react to state changes in our application.

Shall you guess which method we could pass to this subscribe method? 😉

CUSTOM HOOK

Exactly! Now that we have our abstract class, we can use it anywhere in our code. But how do we connect our fancy framework to our previous code?

Well, there can be numerous solutions from here on, and it all depends on the specific framework we're using.

For this example, let's dive into React and create a custom hook! Why not make it fun and enjoy the magic of connecting everything smoothly?:

export default function useSubscription<S, U extends UseCase<S>>(useCase: U, setStateFn: Dispatch<SetStateAction<S>>) {
    useCase.execute();
    useCase.subscribe((state) => setStateFn((prevState) => ({ ...prevState, ...state })));
}
Enter fullscreen mode Exit fullscreen mode

The useSubscription custom hook allows us to call the execute method, which can, for instance, make API calls, and we can also use the subscribe method to pass a function that updates the state of our components:

setStateFn((prevState) => ({ ...prevState, ...state }))
Enter fullscreen mode Exit fullscreen mode

With this magical useSubscription custom hook, we can seamlessly connect our abstract class to React components and enjoy the wonders of reactivity! Now, every state update will trigger the registered functions, ensuring our components stay in sync with the global state. Isn't this a delightful way to handle state management? Let the fun begin!. 🎉

IMPLEMENTATION

Now that we've reached this point, let's see how we implement this custom hook in our React component.

Imagine an application for recipes, and our component wants to fetch recipes from the database. With React, we'd do something similar to this:

const [listRecipes, setListRecipes] = useState<ListRecipesUseCaseState>(listRecipesUseCase.getState())
Enter fullscreen mode Exit fullscreen mode

We'll use useState to manage the state of recipe lists, and we'll initiate it by calling the API with the method:

listRecipesUseCase.getState()
Enter fullscreen mode Exit fullscreen mode

Once again, you got it! Following this, let's use useEffect to subscribe to our global state:

 useEffect(() => {
        useSubscription<ListRecipesUseCaseState, ListRecipesUseCase>(listRecipesUseCase, setListRecipes);
    }, []);
Enter fullscreen mode Exit fullscreen mode

With the useEffect, our component will subscribe to the global state changes, ensuring it stays in sync with any updates that occur. Now, our recipe app is all set to whip up a delightful user experience!😋

SVELTE EXAMPLE

Fantastic! To wrap it up, let's illustrate the same implementation, this time using Svelte:

In Svelte, we'll create a store that exposes a method called setAppState, which will update the state using the appState.set(state) function. :

export const appState = writable(stateUseCase.state, () =>
  stateUseCase.setDefaultState(books)
)

export const setAppState = (state: GlobalState) => {
  appState.set(state)
}
Enter fullscreen mode Exit fullscreen mode

To use this approach, we'll call the subscribe method in our App.svelte file.

Here's how it would look:

beforeUpdate(() => {
    stateUseCase.subscribe(setAppState)
  })
Enter fullscreen mode Exit fullscreen mode

This way, our app will automatically subscribe and update the global state every time we call updateState in our use cases. It creates a seamless and reactive experience, keeping our app in sync with the global state effortlessly

Thank you for joining me on this journey! I hope this has been helpful, and I look forward to your feedback.

Happy coding!

@oscarprdev 🚀🌟

Top comments (0)