MVVM Vue Models
yarn add mvvm-vue-models
# Or
npm i --save mvvm-vue-models
A Model-View-ViewModel state management solution... but what does that even mean?
This library provides utilities to create custom composition functions with managed state.
import { defineViewModel } from 'mvvm-vue-models';
export const useCountViewModel = defineViewModel({
name: 'count',
setup() {
const count = ref(0);
const increment = () => {
count.value++;
};
return {
count,
increment,
};
},
});
The state returned from the setup
function is shared accross all components that call useCountViewModel()
(just like how Vuex's state is shared between components)
<script lang="ts">
import { defineComponent } from 'vue';
import { useCountViewModel } from './CountViewModel.ts';
export default defineComponent({
setup() {
return useCountViewModel();
};
});
</script>
Features
- Full typescript support
🎉 - The same syntax as the Composition API
👏 - Parameterized state
🤖 - Built-in persistence
💾
Docs
View Model Names
defineViewModel
requires a name. Behind the scenes, the state is saved as a Map<ViewModel.name, ViewModel.state>
and whenever you call use*ViewModel
, the state is pulled out of that object based on the name
property.
export const useSomeViewModel = defineViewModel({
name: 'some-name',
setup() {
// ...
},
});
If the name you provide is not unique, you will get a warning in the developer console. This is because you're overwritting the state at that name with two confliting sets of data.
Parameterized State
A view model can be "parameterized" by adding arguments to the setup
function.
import { Ref } from 'vue';
import { defineViewModel } from 'mvvm-vue-models';
export const useTodoViewModel = defineViewModel({
name: 'todo',
setup(id: Ref<number>) {
const todo = ref<Todo | undefined>();
// ...
return {
todo,
};
},
});
All parameters need to be of type
Ref
, otherwise the view model will not be able to react to changes
Now the view model's state is only be shared when useTodoViewModel
is called with the same set of arguments.
const { todo: todoA } = useTodoViewModel(ref(1));
const { todo: todoB } = useTodoViewModel(ref(1));
const { todo: todoC } = useTodoViewModel(ref(2));
todoA === todoB; // -> true
todoC !== todoA; // -> true
todoC !== todoB; // -> true
Since todoA
and todoB
share the same id, 1, the returned todos are the same! But because todoC
is using a different id, 2, it's not equal to the others.
Parameterization Caveots
-
Argument types must be uniquely serailizable to a string
State is kept separate by creating a hash based on the value of each arguments'
toString()
. Passing in aRef<Object>
will not work because every object'stoString()
results in"[object Object]"
, which is not unique.Basic types such as
number
,string
, andboolean
are prefered as arguments. But there are work arounds:- If you're using classes, you can override the
toString
method to return an ID or some other unique representation - If you're using plain objects, you can create a computed ref based on that object's id or some other unique representation
- If you're using classes, you can override the
-
Don't over parameterize your view model
Only include parameters that, when any are changed, should result in separate states
Persistence
By default, all state is cached in memory. When you close or reload the page, that cache is cleared and state forgotten.
const useCountViewModel = defineViewModel({
name: 'count',
persistence: {
storage: localStorage,
},
setup() {
const count = ref(0);
const increment = () => {
count.value++;
};
return {
count,
increment,
};
},
});
By default, all ref
's are persisted and restored. To limit what is persisted or how values are restored, checkout the PersistenceOptions
type.
computed
refs and functions will never be stored because they are based on their definitions
Accessing State Outside a Component
Sometimes, you need to access the state from inside the scope of something other than a component. For example, in a router hook to check if a user is authenticated and can access a certain page.
In this case, you can get the current state from a view model, but it won't be reactive.
const state = useAuthViewModel.getState();
if (!state?.isLoggedIn) {
// Redirect to the forebidden, unauthorized, or login URL
}
The getState
method will return undefined
if:
use*ViewModel
has not been called yet- AND there was no persisted state
Enabling the Logger
By default, logs are disabled. It can be useful to enable logs to see when state is being persisted, what gets restored, etc.
In your application entry point, usually main.ts
, simply enable logs:
import { logger } from 'mvvm-vue-models';
logger.setEnabled(true);
Philosophy
Why not just use Vuex, Redux, Mobx, or any of the other single store state management solutions out there?
Having a single, large object where all the state for your application is stored is not a scalable approach. Yes, it works fine for small applications, but not for much longer as the application grows, it becomes a thorn in the developers side: lots of boilerplate, tests that have little value, etc. Not only is it a point point for developers, but things like account info, search results, local edits, etc are not related, and should not be related (same files) in the codebase.
No, Vuex modules do not solve these problem. Adding solutions ontop of these libraries just makes the situation more complex, harder to read/understand, and mroe difficult for new developers to pick up.
The solution isn't adding libraries ontop of the existing state managment libraries, it's to rethink how state should be managed in the first place. Here's a good presentation by the creator of Vuex on why Vuex v5 is going to be completely different, with no global store.
vue-models
was built by developer who loves Android's ViewModels, saw this presentation on Vuex 5, was working on a React project with a MASSIVE Redux store for work, and got sick of it.
Here are the guiding principles for vue-models
:
- No single global store
- Shared, localized state
- No boilerplate from things like actions/mutations/getters