Search code examples
vue.jsvuexpinia

Why should Pinia/Vuex be preferred over classic approach with service classes?


Pinia/Vuex

Pinia/Vuex, as well as Redux, are designed to be a "single source of truth", where you have a store or multiple stores that hold the application data, available from everywhere.

A Pinia store looks like this:

export let useProductsStore = defineStore('products', () => {
    let data = ref(products);

    function getList (params) {
        return someSearchStuffForProducts(params);
    }

    return {data, getList};
});

Then can be used as:

let productsStore = useProductsStore();
console.log(data, data.value);
productsStore.getList(params);

We can create multiple stores:

let usersStore     = useUsersStore();
let productsStore  = useProductsStore();
let basketStore    = useBasketStore();
let favoritesStore = useFavoritesStore();

Stores can refer to each other:

export let useUsersStore = defineStore('users', () => {
    let productsStore = useProductsStore();
}

export let useBasketsStore = defineStore('basket', () => {
    let productsStore = useProductsStore();
}

//Et cetera

In the end, Pinia/Vuex are tools that provide abilities to retrieve and manipulate data stored in states.

Manager/service classes

But there's another approach, well-established one: manager/service classes.

Previous examples can be rewritten as:

//Define the "single source of truth"
let store = {
    products:      { /* ... */},
    currentUser:   { /* ... */},
    userBasket:    { /* ... */},
    userFavorites: { /* ... */},
};

//Here goes manager classes
class ProductsManager {
    constructor (params) {
        this.state = params.state;
        //...
    }

    getList (params) {
        return someSearchStuffForProducts(params);
    }
}

class UsersManager {
    constructor (params) {
        this.state = params.state;
        //Products manager is injected as a dependency
        this.productsManager = params.productsManager;
        //...
    }
}

class BasketManager {
    constructor (params) {
        this.state = params.state;
        //Products manager is injected as a dependency
        this.productsManager = params.productsManager;
        //...
    }
}

//Some config/initialization script
export let DIC = {}; //Container for manager instances
DIC.productsManager = new ProductsManager({state: store.products});
DIC.usersManager = new usersManager({
    state:           store.currentUser,
    productsManager: DIC.productsManager,
});
DIC.basketManager = new BasketManager({
    state:           store.userBasket,
    productsManager: DIC.productsManager,
});

//Usage
import {DIC} from './config';
DIC.productsManager.getList();
DIC.basketManager.add(someProductId);
DIC.basketManager.changeCount(someProductId, 3);

All of this can be easily typed in TypeScript without additional wrappers, ref(), etc.

Discussion

As far as I can see, Pinia looks like "reinventing the wheel": same functionality written in a clumsy way.

Moreover, it doesn't provide dependency injection: you can't init stores in the config and accurately inject one store to another, you have to hardcode dependencies right into a store, by useProductsStore() and such.

Inheritance or any other OOP stuff aren't possible too.

Pinia even promotes circular dependencies, which leads to spaghetti code with poor maintainability.

So, after all, why should one prefer Pinia/Vuex over battle-tested, clean OOP approach with manager classes? I've been writing my self-invented tutorial project for dozens of hours, using Pinia as "next recommended state management for Vue", and now I feel tempted to rewrite everything into manager classes, as I find Pinia unwieldy and abundant. I just recalled that several years ago I was writing another test project - with Vue2 - I used manager classes then - and everything was working smoothly. Do I overlook something? Will I have problems if I abandon Pinia?


Solution

  • Classes are second-class citizens in Vue reactivity and have some pitfalls. They cannot bind this in a constructor, this will result in using non-reactive class instance instead of reactive proxy. They cannot efficiently use refs because these ones are unwrapped in a documented but abnormal way. They cannot make use of get/set accessors for computed refs. These concerns require to either write a class in an odd way with the explicit use of Vue reactivity API, or design a class in a restricted way, so reactive(new MyClass) won't prevent it from working correctly.

    Classes don't have the features that stores have, like the extensive support for Vue devtools, plugin system, etc.

    Classes are also not serializable in JavaScript, so saving and restoring a state requires custom logic instead of simple JSON (un)serialization like it's done in store persistence plugins.

    Dependency injection isn't unique to classes and can be performed in a suitable way, e.g. for Pinia stores:

    const basketManagerStore = defineStore({
      state: () => ({ _getFoo: null }),
      getters: { 
        foo: state => state._getFoo()
      },
      actions: {
        setFoo(getFoo) {
          this._getFoo = getFoo;
        }
      }
    });
    
    basketManagerStore.setFoo(useSomeFooStore);
    

    It's preferable to deal with Pinia store composables instead of store instances in many cases because this resolves circular dependencies that could be a problem if a composable is called too early. The same problem could appear with classes and require to use DI container instead of using class instances directly.

    There are no problems with inheritance because reusable code can be treated with FP instead of OOP. Vue doesn't explicitly promote it but makes the former more idiomatic and comfortable to use.

    TL;DR: stick to plain objects and FP because this is the primarily case that Vue reactivity was designed for.