If you've used Vuejs, you're familliar with passing primitive data types to your components using props. Did you know that you can also pass callback functions to components in the same way? In this blog post i'll show you how you can take advantage of this unique feature to simplify your components.
Imagine we have an application dashboard built using Laravel Breeze/Inertia that displays some sort of data for the end user. This application has a component that is responsible for pulling information from the server and displaying it in a pretty looking bar graph.
You've possibly seen or built something that looks like this:
<!-- DataGraph.vue -->
<template>
<div v-if="fetchedData">
<bar-graph :data="fetchedData" />
</div>
</template>
<script>
export default {
props: {
// An object that represents a user
user: {
type: Object,
default: null,
}
},
data() {
// Data fetched from the server
fetchedData: null,
},
created() {
// Fetches data from the server when this component is created
this.fetchData();
},
methods: {
/**
Fetches data for the current :user prop from the server
@returns Array
*/
async fetchData()
{
if(!this?.user?.id) {
// Use tightenco/ziggy to get a url for the provided user
const url = route('some.data.route', this.user.id);
// Use an HTTP library to fetch data from this URL.
this.fetchedData = await SomeHTTPLibrary.get(this.url);
}
}
}
}
</script>
This component accepts a user object (typically a JSON serialized model from your Laravel backend) and during the "created" hook, uses this user to fetch data from a backend API route of some sort. This data will then be displayed in a bar graph using some charting library.
Simple enough, and if it does the job for you, great! But there's room for improvement here:
Route is a global function injected by breeze. If we use this global function anywhere in our app like this, we can no longer reuse these components in environments where the global route() function doesn't exist
Http lib is hard coded meaning we cannot test this component without performing an HTTP call of some sort
The graphing component is hard coded to "bar-graph" meaning we cant easily change how this data renders (example: pie chart).
We are assuming the :user prop is fully initialized at boot. If we instead loaded the user asynchronously, this component will fail to work correctly.
We cannot be sure that the dynamic object we're calling user has a property called ID. Assume Typescript is not an option.
The first two are easy enough to fix. Instead of using route and http directly, we can instead pass these functions in as props. Now our component is no longer hard coded to these libraries.
Passing function props in this way operates very similarly to dependency injection in your Laravel controllers, but as we will see in a minute, has a few distinct differences from PHP.
<!-- DataGraph.vue -->
<template>
<div v-if="fetchedData">
<bar-graph :data="fetchedData" />
</div>
</template>
<script>
export default {
props: {
/* An object that represents a user */
user: {
type: Object,
default: null,
},
/*
The function that will generate a backend URL for us.
In this case, were using tighenco/ziggy
*/
router: {
type: Function,
required: true,
},
/*
A function that will perform the HTTP call for us.
Example: Axios
*/
http: {
type: Object,
required: true
},
},
data() {
fetchedData: null,
},
created() {
this.fetchData();
},
methods: {
/**
Fetches data for the current :user prop from the server
@returns Array
*/
async fetchData()
{
if(!this?.user?.id) {
// Use tightenco/ziggy to get a url for the provided user
const url = route('some.data.route', this.user.id);
// Use an HTTP library to fetch data from this URL.
this.fetchedData = await this.http.get(this.url);
}
}
}
}
</script>
We also need to modify DataGraph's parent component. In the template, we pass in ziggy and axios as external dependencies that we will consume with our DataGraph component.
<!-- Parent.vue -->
<template>
<data-graph :router="route" :http="axios" :user="user" />
</template>
<script>
Import axios from "axios";
export default {
props: {
/** The current user */
user: {
type: Object,
required: true,
},
},
methods: { axios, route },
}
</script>
We have now decoupled some dependencies from our component meaning in a testing environment we can pass in fake/mock versions of these dependencies. This allows us to ensure this component is working correctly without actually making an HTTP call. However, this approach still has problems:
It's a pain to have to pass down these functional dependencies to child components each time we need them
This approach isn't really any more reusable than what we had before
We still cannot trust the user object to contain valid data
The reason that these issues are occurring is that we're attempting to use object oriented style dependency injection in a functional environment.
When working in a functional environment, we must approach problems in a functional way. Let's take a step back and think about what we were trying to accomplish in our DataGraph.
A method I find helpful to do this is to write down in plain english exactly what the problem is that we have solved. Something like this:
"The DataGraph component accepts a user and spits out the correct data for that user"
To put this another way, the component does not care about implementation details like routes and http libraries. Instead, our component just wants the data. It acquires this data by passing the user to a function returns data that belongs to that user. The data returned from this function is what will be used to render the component.
By changing the callback function that the component uses to get this data, we can now use any data source in our application to render this component.
Here's what a "functional first" DataGraph component might look like
<!-- DataGraph.vue -->
<template>
<div v-if="fetchedData">
<bar-graph :data="fetchedData" />
</div>
</template>
<script>
export default {
props: {
/* An object that represents a user */
user: {
type: Object,
default: null,
},
/*
A function that accepts a user ID and returns the data for this user.
Returns null by default so that if we have not provided this
prop, our component will just display nothing. Note that
this function accepts an ID and not a user object. This
was done deliberately to make it clear what values we
expect the :user prop to contain
*/
getdata: {
type: Function,
default: (userID) => null,
}
},
data() {
return {
// Data returned from the :getdata prop will be stored here
fetchedData: null,
}
}
watch: {
/** Runs getUserData method if/when :user prop changes.
The reason this isn't done with a computed
property is so that we can provide a
default value
*/
user() {
this.getUserData();
},
// Runs getUserData if/when :getdata prop changes
getdata() {
this.getUserData();
}
}
methods: {
/**
Runs the :getdata functional prop with :user prop as it's argument.
Result is stored in the fetchedData variable
*/
async getUserData()
{
// The getdata function will just return null if we haven't provided
// valid data. Our template will filter out valid data meaning no
// errors can occur
this.fetchedData = await this.getdata(this?.user?.id || null);
}
}
}
</script>
We also need to rewrite the parent component to pass in the expected getData functionn
<!-- Parent.vue -->
<template>
<data-graph :user="user" :getdata="fetchSalesData" />
</template>
<script>
Import axios from "axios";
export default {
props: {
/** The current user */
user: {
type: Object,
required: true,
},
},
computed: {
/**
Returns a callback function that we can use to get the data for the current user.
Note that the :user prop of this class is never referenced directly, instead its
expected that the DataGraph component will pass it's :user prop to this func
The callback returned by this function is then passed to the <data-graph> component
*/
fetchSalesData() {
// This isn't really necessary but helps make it clear to other devs how this works
const http = axios;
return (user) => {
// If user arg doesn't contain an id property just return null
if(! this?.user?.id) {
return null;
}
// We can use the global route function again as we are no longer tying
// our implementation to it.
const url = route('some.data.route', this.user.id);
// Use the provided :http prop function to perform the HTTP call
return http.get(url);
}
}
}
}
</script>
Now we're starting to get somewhere.
We have fixed the issue of never being able to trust the user object prop by making is explicit properties we need :user to contain.
we have fully decoupled our component from the underlying implementation. If we wanted to change from an HTTP call to some other method, it's now trivial to do so.
So far we have taken our business logic, application logic, and template logic and separated it out so we can reconnnect it in any way we choose. We can take this concept one step further by implementing each of these logic types into their own components.
Our DataGraph component really has two jobs so we should first split it into two separate components
A component that accepts a user and returns the data for that user
A component that accepts an array and renders a graph using that array
The former we can accomplish by rewriting our DataGraph component as a "renderless" functional component. This component will still accept a :user prop and a :getdata function callback prop, but will no longer render anything. Instead, this component will simply pass data down to it's child component via slot props. To make it more clear what this component does, we are also going to rename this component to GetUserData.vue
If you haven't seen a render(h) function before, it's what the "templates" in your single file components get transpiled down to by Vue at compile time. We are simply skipping that step and providing a render function directly. A handy trick when all you want to do is pass data to a child component. More info here
<!-- GetUserData.vue
(Formerly DataGraph) -->
<script>
export default {
props: {
/* An object that represents a user */
user: {
type: Object,
default: null,
},
/*
A function that accepts a user ID and returns the data for this user.
Returns null by default so that if we have not provided this
prop, our component will just display nothing. Note that
this function accepts an ID and not a user object. This
was done deliberately to make it clear what values we
expect the :user prop to contain
*/
getdata: {
type: Function,
default: (userID) => null,
}
},
data() {
return {
// Data returned from the :getdata prop will be stored here
fetchedData: null,
}
}
watch: {
// Runs getUserData method if/when :user prop changes
user() {
this.getUserData();
},
// Runs getUserData if/when :getdata prop changes
getdata() {
this.getUserData();
}
}
methods: {
/**
Runs the :getdata functional prop with :user prop as it's argument.
Result is stored in the fetchedData variable
*/
async getUserData()
{
// The getdata function will just return null if we haven't provided
// valid data. Our template will filter out valid data meaning no
// errors can occur
this.fetchedData = await this.getdata(this?.user?.id || null);
}
},
/**
This function is called by vue automatically because this component does
not have a <template> declaration.
This function is roughly equivalent to the following template
<template>
<slot :fetchedData="fetchedData" />
<template>
*/
render(h) {
return this.$scopedSlots.default({
fetchedData: this.fetchedData,
});
}
}
</script>
Now we can just specify the graph we want directly in the parent component
Since Parent.vue only contains logic specific to fetching and rendering this graph, we should rename it to something more useful. Lets assume the http call is pulling down sales data for this user. In this situation, we can just rename Parent.vue to TotalSales.vue.
<!-- TotalSales.vue -->
<template>
<get-user-data :user="user" :getdata="fetchUserData" v-slot="{ fetchedData }">
<bar-graph :data="fetchedData" />
</data-graph>
</template>
<script>
Import axios from "axios";
export default {
props: {
/** The current user */
user: {
type: Object,
required: true,
},
},
computed: {
/**
Returns a callback function that we can use to get the data for the current user.
Note that the :user prop of this class is never referenced directly, instead its
expected that the DataGraph component will pass it's :user prop to this func
*/
fetchSalesData() {
// This isn't really necessary but helps make it clear to other devs how this works
const http = axios;
return (user) => {
// If user arg doesn't contain an id property just return null
if(! this?.user?.id) {
return null;
}
// We can use the global route function again as we are no longer tying
// our implementation to it.
const url = route('some.data.route', this.user.id);
// Use the provided :http prop function to perform the HTTP call
return http.get(url);
}
}
}
</script>
In the parent component where we want to use this "TotalSales" component, we can now do so like this
<!-- Parent.vue -->
<template>
<total-sales :user="user" />
</template>
Our complex but flexible combination of components has been condensed into a single, easy to understand line of code. Wow!
Lets say in the future we want to create a new graph that breaks down sales data by category. We can't reuse our original work, but since we have composed the problem into multiple reusable pieces, we can easily build a new one like playing with lego bricks.
<!-- SalesByCategory.vue -->
<template>
<get-user-data :user="user" :getdata="fetchDataByCatefory" v-slot="{ fetchedData }">
<donut-graph :data="fetchedData" />
</data-graph>
</template>
<script>
Import axios from "axios";
export default {
props: {
/** The current user */
user: {
type: Object,
required: true,
},
},
computed: {
fetchSalesData() {
const http = axios;
return (user) => {
if(! this?.user?.id) {
return null;
}
// Note that this is a different url
const url = route('api.sales.bycategory', this.user.id);
return http.get(url);
}
}
}
}
</script>
We consume this work in exactly the same way
<!-- Parent.vue -->
<template>
<sales-by-category :user="user" />
</template>
This pattern can be repeated over and over to quickly iterate new features in your app.
A story of an internship I did with a governmenmental IT Dept
Some tips on using the new Composition API for VueJS 3
I display a common issue that pops up when using PHP static class methods