JS Vue - Expense Tracker App
Last modified: 12 February 2025This is a guide on how to build a basic app using Vue 3 and the composition API (does not use Pinia or Vue Router)
This is based on the tutorial from Traversy Media:
Expense Tracker YouTube Tutorial
Create a new app
Create a new Vue application, selecting no to all installation options (such as TypeScript, Pinia etc)
open the index.html file and change the title from Vite App to Expense Tracker
Delete everything in the assets folder.
Delete everything in the components folder (including the icons folder).
Open the App.vue file, delete its contents and replace with:
<template>My App</template>
Add the CSS
As this guide is focused on using Vue and not on CSS we will just add the completed styling.
Create a new file within the assets folder and call it style.css, then paste the following contents:
@import url('https://fonts.googleapis.com/css?family=Lato&display=swap');
:root {
--box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
}
* {
box-sizing: border-box;
}
body {
background-color: #f7f7f7;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
font-family: 'Lato', sans-serif;
font-size: 18px;
}
.container {
margin: 30px auto;
width: 400px;
}
h1 {
letter-spacing: 1px;
margin: 0;
}
h3 {
border-bottom: 1px solid #bbb;
padding-bottom: 10px;
margin: 40px 0 10px;
}
h4 {
margin: 0;
text-transform: uppercase;
}
.inc-exp-container {
background-color: #fff;
box-shadow: var(--box-shadow);
padding: 20px;
display: flex;
justify-content: space-between;
margin: 20px 0;
}
.inc-exp-container > div {
flex: 1;
text-align: center;
}
.inc-exp-container > div:first-of-type {
border-right: 1px solid #dedede;
}
.money {
font-size: 20px;
letter-spacing: 1px;
margin: 5px 0;
}
.money.plus {
color: #2ecc71;
}
.money.minus {
color: #c0392b;
}
label {
display: inline-block;
margin: 10px 0;
}
input[type='text'],
input[type='number'] {
border: 1px solid #dedede;
border-radius: 2px;
display: block;
font-size: 16px;
padding: 10px;
width: 100%;
}
.btn {
cursor: pointer;
background-color: #9c88ff;
box-shadow: var(--box-shadow);
color: #fff;
border: 0;
display: block;
font-size: 16px;
margin: 10px 0 30px;
padding: 10px;
width: 100%;
}
.btn:focus,
.delete-btn:focus {
outline: 0;
}
.list {
list-style-type: none;
padding: 0;
margin-bottom: 40px;
}
.list li {
background-color: #fff;
box-shadow: var(--box-shadow);
color: #333;
display: flex;
justify-content: space-between;
position: relative;
padding: 10px;
margin: 10px 0;
}
.list li.plus {
border-right: 5px solid #2ecc71;
}
.list li.minus {
border-right: 5px solid #c0392b;
}
.delete-btn {
cursor: pointer;
background-color: #e74c3c;
border: 0;
color: #fff;
font-size: 20px;
line-height: 20px;
padding: 2px 5px;
position: absolute;
top: 50%;
left: 0;
transform: translate(-100%, -50%);
opacity: 0;
transition: opacity 0.3s ease;
}
.list li:hover .delete-btn {
opacity: 1;
}
Then import this stylesheet into the main.js file:
import './assets/style.css'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
Add the components
Frontend frameworks such as Vue are built around separating your views into components, building them separately and then have them pass information between themselves and be reactive.
Within the components folder create files for the different components:
Header.vue Balance.vue IncomeExpenses.vue TransactionList.vue AddTransaction.vue
open the Header.vue file and add the following:
<template>
<h2>Expense Tracker</h2>
</template>
Open the Balance.vue file and add the following:
<template>
<h4>Your Balance</h4>
<h1 id="balance">$0.00</h1>
</template>
Open IncomeExpenses.vue and add the following:
<template>
<div class="inc-exp-container">
<div>
<h4>Income</h4>
<p id="money-plus" class="money plus">+$0.00</p>
</div>
<div>
<h4>Expense</h4>
<p id="money-minus" class="money minus">-$0.00</p>
</div>
</div>
</template>
Open TransactionList.vue and add the following:
<template>
<h3>History</h3>
<ul id="list" class="list">
<li class="minus">
Cash <span>-£400</span>
<button class="delete-btn">x</button>
</li>
<li class="plus">
Paycheck <span>£800</span>
<button class="delete-btn">x</button>
</li>
</ul>
</template>
Open AddTransaction.vue and add the following:
<template>
<h3>Add new transaction</h3>
<form id="form">
<div class="form-control">
<label for="text">Text</label>
<input type="text" id="text" placeholder="Enter text..." />
</div>
<div class="form-control">
<label for="amount"
>Amount <br />
(negative - expense, positive - income)</label
>
<input type="text" id="amount" placeholder="Enter amount..." />
</div>
<button class="btn">Add transaction</button>
</form>
</template>
Next, we will import these components into the main App.vue file to display them.
Open App.Vue file, add script setup tags and add the imports for the components.
Then update the template with the Header and Balance components:
<template>
<Header />
<div class="container">
<Balance />
<IncomeExpenses />
<TransactionList />
<AddTransaction />
</div>
</template>
<script setup>
import Header from './components/Header.vue';
import Balance from './components/Balance.vue';
import IncomeExpenses from './components/IncomeExpenses.vue';
import TransactionList from './components/TransactionList.vue';
import AddTransaction from './components/AddTransaction.vue';
</script>
Updating the TransactionList component
We will start with the TransactionList component.
We will want the values to be passed down from App.vue, for small applications you can store your variables in App.vue so they can be passed down to be used in all your components. In a larger application, you would use a dedicated global store such as Pinia.
But for now, while we are working on this component, we will use some hard-coded values which we will add by adding a script setup tag and then adding the values to a variable using standard JS syntax, and commenting out the current hard-coded values in the HTML:
<template>
<h3>History</h3>
<ul id="list" class="list">
<!-- <li class="minus">-->
<!-- Cash <span>-£400</span>-->
<!-- <button class="delete-btn">x</button>-->
<!-- </li>-->
<!-- <li class="plus">-->
<!-- Paycheck <span>£800</span>-->
<!-- <button class="delete-btn">x</button>-->
<!-- </li>-->
</ul>
</template>
<script setup>
const transactions = [
{ id: 1, text: 'Flower', amount: -19.99},
{ id: 2, text: 'Salary', amount: 299.97},
{ id: 3, text: 'Book', amount: -10},
{ id: 4, text: 'Camera', amount: 150},
]
</script>
We want to create a list item for each item in our array. We do this by adding a new li tag:
<li></li>
Then we add a Vue directive to state that we want to create one li for each element in our array, we can do this with a for loop:
<li v-for="transaction in transactions">
</li>
As is the case in standard JS the word transaction used above could be any word of your choosing, and this will represent one item in the transactions array.
Vue uses a key attribute to track elements more efficiently. When Vue updates the DOM, it uses the key as a unique identifier to determine which elements have changed. Without a key Cue may reuse DOM elements incorrectly, which can lead to rendering bugs or poor performance.
We will use the id value in our elements as the key:
<li v-for="transaction in transactions" :key="transaction.id">
</li>
Next copy from the commented out HTML we have the contents between one of the li tags, and add it to out new looped li tag (then delete the previously commented out code):
<template>
<h3>History</h3>
<ul id="list" class="list">
<li v-for="transaction in transactions" :key="transaction.id">
Cash <span>-£400</span>
<button class="delete-btn">x</button>
</li>
</ul>
</template>
We can add conditional styling by using Vue bind on a class, this allows us to only add classes under certain conditions. In this case, we need to add a class of 'minus' when the transaction amount is negative or 'plus if it is positive:
<li v-for="transaction in transactions"
:key="transaction.id"
:class="transaction.amount < 0 ? 'minus' : 'plus'">
When viewing the app you will now see four entries for Cash -£400, as we have four items in our array.
To display the actual values, we need to reference them inside double curly braces, so we would replace the hard-coded 'Cash' with {{ transaction.text }}
, and the hard-coded '-£400' with {{ transaction.amount }}
<template>
<h3>History</h3>
<ul id="list" class="list">
<li v-for="transaction in transactions"
:key="transaction.id"
:class="transaction.amount < 0 ? 'minus' : 'plus'">
{{ transaction.text }} <span>{{ transaction.amount }}</span>
<button class="delete-btn">x</button>
</li>
</ul>
</template>
<script setup>
const transactions = [
{id: 1, text: 'Flower', amount: -19.99},
{id: 2, text: 'Salary', amount: 299.97},
{id: 3, text: 'Book', amount: -10},
{id: 4, text: 'Camera', amount: 150},
]
</script>
As mentioned earlier, we do not want these values to be within this component, we want them to be in the App.vue and then passed down. So cut the transactions' array from this component, and add it to the App.vue file in the script there:
<template>
<Header />
<div class="container">
<Balance />
<IncomeExpenses />
<TransactionList />
<AddTransaction />
</div>
</template>
<script setup>
import Header from './components/Header.vue';
import Balance from './components/Balance.vue';
import IncomeExpenses from './components/IncomeExpenses.vue';
import TransactionList from './components/TransactionList.vue';
import AddTransaction from './components/AddTransaction.vue';
const transactions = [
{id: 1, text: 'Flower', amount: -19.99},
{id: 2, text: 'Salary', amount: 299.97},
{id: 3, text: 'Book', amount: -10},
{id: 4, text: 'Camera', amount: 150},
]
</script>
We need our data to be reactive, and to do this we use a Vue funtion called ref.
First import the ref function from vue, and then wrap the values in our transactions array with the ref function:
<template>
<Header/>
<div class="container">
<Balance/>
<IncomeExpenses/>
<TransactionList/>
<AddTransaction/>
</div>
</template>
<script setup>
import Header from './components/Header.vue';
import Balance from './components/Balance.vue';
import IncomeExpenses from './components/IncomeExpenses.vue';
import TransactionList from './components/TransactionList.vue';
import AddTransaction from './components/AddTransaction.vue';
import {ref} from "vue";
const transactions = ref([
{id: 1, text: 'Flower', amount: -19.99},
{id: 2, text: 'Salary', amount: 299.97},
{id: 3, text: 'Book', amount: -10},
{id: 4, text: 'Camera', amount: 150},
])
</script>
To pass the data down to our TransactionList component, we pass it as a prop.
To do this bind the name you would like to call the prop to the TransactionList tag and set it equal to the data you would like to pass:
<template>
<Header/>
<div class="container">
<Balance/>
<IncomeExpenses/>
<TransactionList :transactions="transactions"/>
<AddTransaction/>
</div>
</template>
<script setup>
import Header from './components/Header.vue';
import Balance from './components/Balance.vue';
import IncomeExpenses from './components/IncomeExpenses.vue';
import TransactionList from './components/TransactionList.vue';
import AddTransaction from './components/AddTransaction.vue';
import {ref} from "vue";
const transactions = ref([
{id: 1, text: 'Flower', amount: -19.99},
{id: 2, text: 'Salary', amount: 299.97},
{id: 3, text: 'Book', amount: -10},
{id: 4, text: 'Camera', amount: 150},
])
</script>
To import the prop into our TransactionList component, we need to create a new variable called props and assign it to defineProps(). Then within the function add the details for the prop we wish to receive, which in our case is transactions:
<script setup>
const props = defineProps(['transactions']);
</script>
You should now see that the app now works again displaying the values.
Additional details can be added about the prop to specify its type, and if it is required:
x<script setup>
const props = defineProps({
transactions: {
type: Array,
required: true,
}
})
</script>
Update the Balance component
To create a variable that works out a calculation and is reactive, we can use the Vue computed function, We will use this to calculate the total in our app.
In App.Vue import the computed function from vue by adding it to our import statement:
import {ref, computed} from "vue";
Then create a new variable called total and assign it to a computed arrow function:
const total = computed(() => {})
Then return the calculation we want, here I am using reduce to calculate the total:
const total = computed(() => {
return transactions.value.reduce((acc, transaction) => {
return acc + transaction.amount;
}, 0)
})
Pass the value of total to the Balance component as a prop:
<template>
<Header/>
<div class="container">
<Balance :total="total"/>
<IncomeExpenses/>
<TransactionList :transactions="transactions"/>
<AddTransaction/>
</div>
</template>
In the Balance component, create the props variable, and then add it to the HTML:
<template>
<h4>Your Balance</h4>
<h1 id="balance">{{ total }}</h1>
</template>
<script setup>
const props = defineProps({
total: {
type: Number,
required: true,
}
})
</script>
Update the IncomeExpenses Component
In App.vue add two more computed properties to calculate the income and the expenses:
const income = computed(() => {
return transactions.value
.filter((transaction) => transaction.amount > 0)
.reduce((acc, transaction) => {
return acc + transaction.amount;
}, 0).toFixed(2);
})
const expenses = computed(() => {
return transactions.value
.filter((transaction) => transaction.amount < 0)
.reduce((acc, transaction) => {
return acc + transaction.amount;
}, 0).toFixed(2);
})
And then pass them to the IncomeExpenses component as props, add a plus symbol in front of the variable names to convert them from strings to integers (unary plus operator in JavaScript):
<template>
<Header/>
<div class="container">
<Balance :total="total"/>
<IncomeExpenses :income="+income" :expenses="+expenses"/>
<TransactionList :transactions="transactions"/>
<AddTransaction/>
</div>
</template>
Then in the IncomeExpenses component, as we have done before, add the props, and then use them in the view:
<template>
<div class="inc-exp-container">
<div>
<h4>Income</h4>
<p id="money-plus" class="money plus">£{{ income }}</p>
</div>
<div>
<h4>Expense</h4>
<p id="money-minus" class="money minus">£{{ expenses }}</p>
</div>
</div>
</template>
<script setup>
const props = defineProps({
income: {
type: Number,
required: true,
},
expenses: {
type: Number,
required: true,
}
})
</script>
Updating the AddTransaction component
This component is different to the ones we have created so far, as rather than receive data from App.Vue, we need to pass data to it.
Add a function call to the form element called onSubmit:
<template>
<h3>Add new transaction</h3>
<form id="form" @submit.prevent="onSubmit()">
<div class="form-control">
<label for="text">Text</label>
<input type="text" id="text" placeholder="Enter text..." />
</div>
<div class="form-control">
<label for="amount"
>Amount <br />
(negative - expense, positive - income)</label
>
<input type="text" id="amount" placeholder="Enter amount..." />
</div>
<button class="btn">Add transaction</button>
</form>
</template>
Create the onSubmit function and have it print to console to check that it works on submitting the form:
<script setup>
const onSubmit = () => {
console.log('submit');
}
</script>
We need to bind the values from the input fields to variables we can submit in our onSubmit function.
We do this using the v-model directive. Add a v-model directive to both of the inputs:
<template>
<h3>Add new transaction</h3>
<form id="form" @submit.prevent="onSubmit()">
<div class="form-control">
<label for="text">Text</label>
<input type="text" id="text" v-model="text" placeholder="Enter text..." />
</div>
<div class="form-control">
<label for="amount"
>Amount <br />
(negative - expense, positive - income)</label
>
<input type="text" id="amount" v-model="amount" placeholder="Enter amount..." />
</div>
<button class="btn">Add transaction</button>
</form>
</template>
Import the ref function from vue and then create two new variables for our inputs and assign them to an empty ref function. Then pass the values into the console log to check they are being picked up correctly:
<script setup>
import {ref} from 'vue';
const text = ref('');
const amount = ref('');
const onSubmit = () => {
console.log(text.value, amount.value);
}
</script>
Form validation using 3rd party plugin
We will add validation to our form to ensure that users cannot submit an empty form.
We will use a third party plugin called Vue toastification to display them nicely on screen.
Install the package by running the following in the terminal:
npm install --save vue-toastification@next
Next, we need to update the main.js file to import and use the package.
Add the following two imports to add the package and a stylesheet that it uses:
import Toast from 'vue-toastification'
import 'vue-toastification/dist/index.css'
Then, to use it in the app, we will rewrite the createApp function to allow us to configure the application with additional plugins, middleware, or other global features before mounting it to the DOM:
const app = createApp(App)
app.mount('#app')
Then to use the plugin we call it before app.mount:
const app = createApp(App)
app.use(Toast)
app.mount('#app')
So the completed main.js file should look something like this:
import { createApp } from 'vue'
import Toast from 'vue-toastification'
import 'vue-toastification/dist/index.css'
import './assets/style.css'
import App from './App.vue'
const app = createApp(App)
app.use(Toast)
app.mount('#app')
Now we can import and use the plugin in our AddTransaction component.
In the script section of AddTransaction.vue import toast:
import {useToast} from 'vue-toastification';
Then assign it to a variable:
const toast = useToast();
Now we can update our onSubmit function, remove the console log, and replace it with an if statement that checks if both fields are empty. If they are, call an error function on toast and pass in the message to display. Then set the values of the fields to an empty string to clear them:
const onSubmit = () => {
if(!text.value || !amount.value) {
toast.error('Both fields most be filled');
}
text.value = '';
amount.value = '';
}
Pass data from component to App.vue (emit)
To pass the inputted transaction data up to App.vue, we need to emit an event.
create a variable called emits and assign it to the defineEmits method, then pass in the name of the method we would like to call, we will call the method transactionsSubmitted:
const emit = defineEmits(['transactionSubmitted']);
Then we will update our onSubmit function with the emit and the data we want to pass up:
const onSubmit = () => {
if(!text.value || !amount.value) {
toast.error('Both fields most be filler');
}
const transactionData = {
text: text.value,
amount: parseFloat(amount.value)
}
emit('transactionSubmitted', transactionData);
text.value = '';
amount.value = '';
}
So here we have created a variable called transactionData and set it to a new object containing the text and amount, Then we have created an emit, passing in the name of the emit and then the data we want to pass.
In the App.vue file, we need to listen for the emit in the template. Add an @ followed by the name of the emit and set it equal to a function called handleTransactionSubmitted:
<template>
<Header/>
<div class="container">
<Balance :total="+total"/>
<IncomeExpenses :income="+income" :expenses="+expenses"/>
<TransactionList :transactions="transactions"/>
<AddTransaction @transactionSubmitted="handleTransactionSubmitted"/>
</div>
</template>
Next, in the script section, create the handleTransactionSubmitted as an arrow function passing in the name of the data we are receiving (the transactionData object). Next, add a console log printing the data, then add a transaction to see if it is working:
const handleTransactionSubmitted = (transactionData) => {
console.log(transactionData);
}
What we want to do next is add the trnsactionData to our existing transactions array, which is:
const transactions = ref([
{id: 1, text: 'Flower', amount: -19.99},
{id: 2, text: 'Salary', amount: 299.97},
{id: 3, text: 'Book', amount: -10},
{id: 4, text: 'Camera', amount: 150},
])
So we will remove the console log from our function and push a new object into our transactions' array, containing the information we require (id, text and amount). For id we will need to create a helper function to generate a unique id for us, so we will add the function call first and then create that function:
const handleTransactionSubmitted = (transactionData) => {
transactions.value.push({
id: generateUniqueId(),
text: transactionData.text,
amount: transactionData.amount,
});
}
Then add the helper function to generate the unique id:
const generateUniqueId = () => {
return Math.floor(Math.random() * 1000000)
}
You should now be able to add an entry and have it appear in the list of transactions.
We will now again use toast to display feedback that a transaction was added succsesfuly.
First we need to import toast into App.vue:
import { useToast} from "vue-toastification";
Then assign it to a variable:
const toast = useToast();
Then update the handleTransactionSubmitted function, to add a toast success call:
const handleTransactionSubmitted = (transactionData) => {
transactions.value.push({
id: generateUniqueId(),
text: transactionData.text,
amount: transactionData.amount,
});
toast.success("Transaction added");
}
Deleting a transaction
Next we will add the ability to delete an entered transaction.
We will do this within the TransactionList component as this is where the delete button is in the template.
Add a click event to the button in the template, and have it call a function called deleteTransaction and pass in the transaction.id:
<template>
<h3>History</h3>
<ul id="list" class="list">
<li v-for="transaction in transactions"
:key="transaction.id"
:class="transaction.amount < 0 ? 'minus' : 'plus'">
{{ transaction.text }} <span>{{ transaction.amount }}</span>
<button @click="deleteTransaction(transaction.id)" class="delete-btn">x</button>
</li>
</ul>
</template>
Then create a new emit called transactionDeleted:
const emit = defineEmits(['transactionDeleted']);
Then create the deleteTransaction function to pass the id with the emit:
const deleteTransaction = (id) => {
emit('transactionDeleted', id);
}
So when the button is clicked, the deleteTransaction function is called passing along the transaction id. Then the function passes up the data to App.vue as an emit with a key of transactionDeleted and a value of the id.
Now we need to switch back to App.vue and listen for the emit. Update the TransactionList component in the template to listen for the transactionDeleted emit and have it call a function called handleTransactionDeleted:
<template>
<Header/>
<div class="container">
<Balance :total="+total"/>
<IncomeExpenses :income="+income" :expenses="+expenses"/>
<TransactionList :transactions="transactions" @transactionDeleted="handleTransactionDeleted"/>
<AddTransaction @transactionSubmitted="handleTransactionSubmitted"/>
</div>
</template>
Create the handleTransactionDeleted function, pass in the id, then at first just have it console log the id value to ensure it is working:
const handleTransactionDeleted = (id) => {
console.log(id);
}
Click the delete button for the transaction in the app and ensure that the appropriate id is being logged.
Now remove the console log and replace it with the functionality we need.
We need to replace the value of the transaction array with a version of the array with the appropriate entry filtered out. Then add a toast to state that the message was deleted:
const handleTransactionDeleted = (id) => {
transactions.value = transactions.value.filter((transaction) => transaction.id !== id);
toast.success("Transaction deleted");
}
To further explain the filter:
transactions.value = transactions.value.filter((transaction) => transaction.id !== id);
The reason we need to say transactions.value instead of just transactions is because the transactions object is a vue ref object, and its actual value is stored inside the .value property of the ref. So transactions on its own just refers to the wrapper object. This reads as set the value of the transactions array to the value of the transactions array but filter to only include the transactions whose id does not match the passed in id.
Local storage
The final step is to add the ability to add and retrieve transaction data from browser local storage.
First go to App.vue and delete the data in the transactions array, leaving an empty ref:
const transactions = ref([])
When the app loads, we need to check if data for our app already exists. We can do this with one of vues lifecycle functions called onMounted, which will run when the app loads. Add onMounted to the list of imports from vue:
import {ref, computed, onMounted} from "vue";
Create the onMounted function, use JSON.parse as the local storage only holds strings:
onMounted(() => {
const savedTransactions = JSON.parse(localStorage.getItem('transactions'))
if(savedTransactions) {
transactions.value = savedTransactions;
}
})
Next, we will write a simple function to save the data to local storage:
const saveTransactionsToLocalStorage = () => {
localStorage.setItem('transactions', JSON.stringify(transactions.value));
}
Then finally, we just need to call this function when we save or delete a transaction, as in either case we are saving to local storage:
const handleTransactionSubmitted = (transactionData) => {
transactions.value.push({
id: generateUniqueId(),
text: transactionData.text,
amount: transactionData.amount,
});
saveTransactionsToLocalStorage();
toast.success("Transaction added");
}
const handleTransactionDeleted = (id) => {
transactions.value = transactions.value.filter((transaction) => transaction.id !== id);
saveTransactionsToLocalStorage();
toast.success("Transaction deleted");
}
And that's it! The app is now complete.