Wednesday, March 3, 2021

Vue.js - Vuex

So far we have learned how to handle data (or we called state) for a single component (via data properties) or multiple components (by Provide and Inject pattern).


Local State:

    Affect one component.

    Ex: component instance properties


Global State:

    Affect multiple components or entire app.

    Ex: authenticate data (JWT)


However, it is really difficult to manage state in global level

    1. You might need to add lots of logic in App.vue

    2. It is hard to track the state change.

    3. It is error-prone for updating state


Then Vuex can help!

    1. Outsourced state management

    2. Predictable state management/flow

    3. Less errors (clearly defined data flow)


Installation



$ npm install vuex@next



Before using Vuex



It is an basic example to use 'properties' for local state. (Without Vuex)

Ex:

// App.vue

<template>
    <the-counter></the-counter>
</template>

<script>
import TheCounter from './components/TheCounter.vue';

export default {
     components: {
         'the-counter': TheCounter,
    },
};
</script>


// components/TheCounter.vue


<template>
<!-- Bind to properties -->
     <h1>{{ counter }}</h1>
     <button v-on:click="increase">Add 1</button>
</template>

<script>
export default {
// Using properties
     data() {
         return {
             counter: 0,
        };
     },
    methods: {
         increase() {
             this.counter++;
        },
     },
};
</script>



Using state



We can use 'createStore' provided by Vuex to create a store for our Vue app.

A 'store' can maintain all the state (data) in Vue application level.

Ex:

// main.js

import { createApp } from 'vue';
import { createStore } from 'vuex';
import App from './App.vue';
// Create store
const store = createStore({
     state() {
         return {
             counter: 0,
        };
     }
});

const app = createApp(App);

// Install plugin
app.use(store);

app.mount('#app');


// components/TheCounter.vue

<template>
<!-- Using state in store -->
    <h1>{{ $store.state.counter }}</h1>
     <button v-on:click="increase">Add 1</button>
</template>

<script>
export default {
     methods: {
         increase() {
// Update state directly
             this.$store.state.counter++;
        },
     },
};
</script>



Using mutations



According to previous example, it is not ideal to change the state of store directly.

Imagine that if your app get bigger and lots of place change the state directly.

Then it is hard to control and track where and why the state got changed.

We should use mutations provided by Vuex to update state.

Ex:

// main.js

const store = createStore({
     state() {
         return {
             counter: 0,
        };
     },
// Define mutations
    mutations: {
         increase(state) {
             state.counter++;
        }
     }
});


// components/TheCounter.vue

<template>
    <h1>{{ $store.state.counter }}</h1>
     <button v-on:click="increase">Add 1</button>
</template>

<script>
export default {
     methods: {
         increase() {
// Send a commit for triggering mutations
             this.$store.commit('increase');
        },
     },
};
</script>



Passing data to mutations is possible



From previous example, we might want to control the increase value when button was clicked.

Refer to this.

Ex:

// main.js

const store = createStore({
    state() {
         return {
             counter: 0,
        };
     },
    mutations: {
         // In most cases, payload should be object (flexibility)
         increase(statepayload) {
             state.counter += payload.amount;
        }
     }
});


// components/TheCounter.vue

<template>
    <h1>{{ $store.state.counter }}</h1>
     <button v-on:click="increase">Add 1</button>
</template>

<script>
export default {
     methods: {
         increase() {
             // It is Object-style commit
            this.$store.commit({
                 type: 'increase',
                 amount: 5,
             });
        },
     },
};
</script>



Using getters?



If we need to compute a state with others (like the case when using 'computed' in component), we need to copy computing logic to all components if used. Duplicate code is not what we want.

Vuex provide getter for us.

Ex:

// main.js

const store = createStore({
    state() {
         return {
             counter: 0,
        };
     },
    getters: {
         // Computing the final value of counter
         finalCounter(state) {
             return state.counter * 2;
        }
     },
    mutations: {
         increase(statepayload) {
             state.counter += payload.amount;
        }
     }
});


// components/TheCounter.vue

<template>
     <!-- Using computed value -->
    <h1>{{ counter }}</h1>
     <button v-on:click="increase">Add 1</button>
</template>

<script>
export default {
     computed: {
         counter() {
             // Using getter to get the final counter
            return this.$store.getters.finalCounter;
         },
    },
     methods: {
         increase() {
            this.$store.commit({
                 type: 'increase',
                 amount: 5,
            });
         },
    },
};
</script>



Using actions



In realistic project, we need to send data to server to update database, and we will update the view depends on the web api response.

And since web api call is asynchronous, we cannot use mutations (it must be synchronous) to update state.

Vuex provide actions for us.

Ex:

// main.js

const store = createStore({
    state() {
         return {
             counter: 0,
        };
     },
    getters: {
         finalCounter(state) {
             return state.counter * 2;
        }
     },
    mutations: {
         increase(statepayload) {
             state.counter += payload.amount;
        }
     },
    actions: {
         increase(contentpayload) {
             // Force to have a delay for exp only
             setTimeout(function () {
                 content.commit('increase'payload)
            }, 5000)
         }
    }
});


// components/TheCounter.vue
<template>
     <h1>{{ counter }}</h1>
    <button v-on:click="increase">Add 1</button>
</template>

<script>
export default {
     computed: {
         counter() {
            return this.$store.getters.finalCounter;
         },
    },
     methods: {
         increase() {
             // Trigger actions
            this.$store.dispatch({
                 type: 'increase',
                 amount: 5,
             });
        },
     },
};
</script>


NOTE:

Refer to this.

You cat dispatch action to another action!

For example, you can dispatch to succeed/failure actions after web api call actions.


Mapper Helper



So far you might feel that the code is verbose such as we have used lots of code like 'this.$store.getters.xxx' and 'this.$store.dispatch(xxx)'.

Vuex provides mapper helper to save your code.

Ex:

// components/TheCounter.vue
<template>
    <!-- bind to getters -->
     <h1>{{ finalCounter }}</h1>
    <!-- bind to actions -->
     <button v-on:click="increase({ amount: 5 })">Add 1</button>
</template>

<script>
import { mapGettersmapActions } from 'vuex';
export default {
     computed: {
// map helper for getters
         ...mapGetters(['finalCounter']),
    },
     methods: {
// map helper for actions
         ...mapActions(['increase']),
    },
};
</script>



Modules



If our app get bigger, you may want to find a way to manage the state based on features.

Below is an example if  we have counter and auth features.

Ex:

// main.js

const store = createStore({
    state() {
         return {
             counter: 0,
             isLoggedIn: false,
        };
     },
    getters: {
         finalCounter(state) {
             return state.counter * 2;
        },
         isUserAuthenticated(state) {
             return state.isLoggedIn;
        }
     },
    mutations: {
         increase(statepayload) {
             state.counter += payload.amount;
        },
         setAuth(statepayload) {
             state.isLoggedIn = payload.isAuth;
        }
     },
    actions: {
         increase(contentpayload) {
             content.commit('increase'payload)
        },
         login(content) {
             content.commit('setAuth', { isAuth: true })
        },
         logout(content) {
             content.commit('setAuth', { isAuth: false })
        },
     }
});

const app = createApp(App);
app.use(store);
app.mount('#app');


We can use module to separate it to make the code clean and readable.

Ex:
// main.js
// Counter feature module
const counter = {
    state() {
         return {
             counter: 0,
        };
     },
    getters: {
         finalCounter(state) {
             return state.counter * 2;
        },
     },
    mutations: {
         increase(statepayload) {
             state.counter += payload.amount;
        },
     },
    actions: {
         increase(contentpayload) {
             content.commit('increase'payload)
        },
     },
}

// Root Module
const store = createStore({
// Setup feature modules
     modules: {
         'counter': counter,
    },
     state() {
         return {
             isLoggedIn: false,
        };
     },
    getters: {
         isUserAuthenticated(state) {
             return state.isLoggedIn;
        }
     },
    mutations: {
         setAuth(statepayload) {
             state.isLoggedIn = payload.isAuth;
        }
     },
    actions: {
         login(content) {
             content.commit('setAuth', { isAuth: true })
        },
         logout(content) {
             content.commit('setAuth', { isAuth: false })
        },
     }
});

const app = createApp(App);
app.use(store);
app.mount('#app');


Note that once you separate the modules, the feature module need to use 'rootState' to access state from root module.


Name clashes?



Refer to this and that.

Ex:

// main.js

const counter = {
    // Enable namespaced feature
     namespaced: true,
    state() {
         return {
             counter: 0,
        };
     },
    getters: {
         finalCounter(state) {
             return state.counter * 2;
        },
     },
    mutations: {
         increase(statepayload) {
             state.counter += payload.amount;
        },
     },
    actions: {
         increase(contentpayload) {
             content.commit('increase'payload)
        },
     },
}


// components/TheCounter.vue

<template>
    <!-- bind to getters -->
     <h1>{{ finalCounter }}</h1>
    <!-- bind to actions -->
     <button v-on:click="increase({ amount: 5 })">Add 1</button>
</template>

<script>
import { mapActions } from 'vuex';
export default {
     computed: {
         // Using getters with namespace feature
        finalCounter() {
             return this.$store.getters['counter/finalCounter'];
         },
    },
     methods: {
         // Using map helper with namespace feature
        ...mapActions('counter', ['increase']),
     },
};
</script>



Structuring



According to previous example, you may notice that main.js get bigger and bigger, and it is hard to manage now.

We can organize our app as below.

// Structure

    src
     ├─── components
     ├─── store
     │       ├─── modules
     │       │       └─ counter
     │       │             ├── actions.js
     │       │             ├── getters.js
     │       │             ├── index.js
     │       │             └── mutations.js
     │       ├─── actions.js
     │       ├─── getters.js
     │       ├─── index.js
     │       └─── mutations.js
     ├─── App.vue
     └─── main.js



Ex:

// main.js

import { createApp } from 'vue';
import App from './App.vue';
import store from './store/index.js';

const app = createApp(App);
// Install plugin
app.use(store);
app.mount('#app');


// store/index.js

import { createStore } from 'vuex';
import counterModule from './modules/counter/index.js';
import rootGetters from './getters.js'
import rootMutations from './mutations.js';
import rootActions from './actions.js';

// Root Modules
export default createStore({
     modules: {
// Setup Counter Feature Module
         'counter': counterModule,
    },
     state() {
        return {
             isLoggedIn: false,
         };
    },
     getters: rootGetters,
    mutations: rootMutations,
     actions: rootActions,
});


// store/actions.js

export default {
    login(content) {
         content.commit('setAuth', { isAuth: true })
    },
     logout(content) {
        content.commit('setAuth', { isAuth: false })
     },
}


// store/modules/counter/index.js

import counterActions from './actions.js';
import counterGetters from './getters.js';
import counterMutations from './mutations.js';

// Counter Feature Module
export default {
    namespaced: true,
     state() {
         return {
             counter: 0,
        };
     },
    getters: counterGetters,
     mutations: counterMutations,
    actions: counterActions,
}


No comments:

Post a Comment