Simple Photo App with Vue.js, Axios and Flickr API — Part 2
Refactoring Practice
Part 1 | Part 2
Welcome back! Previously we laid the groundwork for a simple Vue-based photo app that consumes the Flickr API via the Axios library. We successfully made our first request with the flickr.photos.search
method and got images displaying nicely on the page.
Before we start adding too many new pages and API calls, I thought this would be a good moment to practice refactoring. In this segment, we’ll streamline how we go about calling Flickr methods. We’ll also improve the user experience of our app by adding a new SearchResults.vue
page as well as a more visual loading effect for our image cards. Major thanks to Cameron Bothner for contributing many of these improvements!
Step 1: Refactor the Flickr API call
For any given API call we make in this project, there will be a fair amount of repetition. All of our GET
calls will have the same method, url
, api_key
, format
, etc.
To reduce this redundancy and keep our code DRY (don’t repeat yourself), let’s create a helper function called flickr
in a separate file. This helper function will accept two arguments: method
and params
. method
will be a string like 'photos.search'
that we incorporate on line 13 below. params
will be an object of any extra options we pass in that are needed by that particular method, such as the tag
string. We use this in the flickr
method below by spreading it into the params
object (line 12).
Add a new file named flickr.js
inside src
with the following contents:
Now flickr.js
is the one using axios
and our api_key
inside config.js
. From now on we can import flickr.js
's default function flickr()
for any GET
request we want to make!
Update Home.vue
:
- Import
flickr.js
and removing import statements foraxios
andconfig
.flickr.js
now usesaxios
andconfig
so we don’t need them here. - Simplify the
search()
method. I previously had the API-related code spread between two functions:search()
andfetchImages()
. To make things more readable, let’s consolidate all the API stuff and data handling to be infetchImages()
. Empty out thesearch()
method so it only does three things: update theloading
status totrue
, callfetchImages()
, then setloading
back tofalse
. - Update
fetchImages()
to use the exportedflickr
method fromflickr.js
. Remember, it accepts two arguments, the rest of the method name we want to call (in this case we’re callingflickr.photos.search
so we add'photos.search'
), and parameters that are more unique to that method (in this case,tags
,extras
,page
, andper_page
). Like theaxios()
method before, theflickr
method returns a Promise that we resolve by assigning photo data tothis.images
. Now all the API/data handling is done in one place.
Step 2: Make new views and components
Right now we only have one page,Home.vue
, where users search by keyword and see results. That actually sounds like a search results page, so let’s refactor what we’ve got into a few new pieces.
We’ll start by making a new route for a search results page, then we’ll create a NavBar.vue
component that will be available on all pages, and we’ll finish by converting Home.vue
into SearchResults.vue
.
Step 2.1: Add a new route
Update router.js
to include the following:
According to the above, our app will now have two pages/routes: Home and Search Results. We import Home
and SearchResults
from their respective .vue
files in the views
directory first. Then inside the routes
array we specify the path we want to show them at (path
), give them a name for easy reference in other parts of our app (name
), and state which component we want to use for each route (component)
.
The route object for searchResults
contains two extra things: the path
string (line 18) has a dynamic tag
property that represents whatever tag keyword a user is searching for. Then line 21 declares props
as true, which means we’ll pass in the tag
parameter to the SearchResults
component as a prop
by that name. That’s how our SearchResults
component will know what tag keyword to search for.
Step 2.2: Create a NavBar component
We want users to be able to search from any page in our app, so the search/nav bar should be its own component that lives in the root of our project, App.vue
. Make a file called NavBar.vue
inside the components
directory with the following:
The template is exactly the same as what we’ve been using in Home.vue
. The difference lies in the search()
method that gets executed. NavBar.vue
will not handle the actual API request. That will be done in SearchResults.vue
. So the only thing our NavBar
component is responsible for is sending the user to the search results page and passing along the tag keyword they entered. That is what line 32 does:
this.$router.push({name: 'searchResults', params: {tag: this.tag}})
We update what route the user is at with $router
's push
method, which accepts an object with a few arguments: the name of the route we want to go to and any extra parameters that it is expecting. We want to go to the searchResults
route so that’s the name we enter. That route also has a :tag
parameter, so we pass this.tag
to it. Once that is done we reset the NavBar
's tag
data property to an empty string, so the old search term doesn’t persist.
The last step here is to add NavBar.vue
to App.vue
, so it appears on every page:
- Import the component (line 9 above)
- Register it (line 13)
- Put it in the template (line 3)
Step 2.3: Convert Home.vue to SearchResults.vue
Start by renaming Home.vue
to SearchResults.vue
. Then create a new Home.vue
file in views
that looks like this:
SearchResults.vue
needs a few more updates. In router.js
we said that the SearchResults
view would receive the tag
as a prop
, so add a props
section with the tag
name declared. Line 16 also specifies that it will be of type String
. In the template, line 7 below adds an h1
tag to display the term the user searched for, since it no longer shows in the search input area.
You should now be able to enter a search term on the homepage and end up at the /search/:tag
route with your search term displayed:
So we’re on the right component and our tag is getting passed in correctly… but we’re not seeing any results. That’s because we need to change the way we make our API call. Before we were doing it by clicking the “Go” button, but that serves a different purpose now (to take us to the search results page and pass along the search term).
We need to somehow call the search()
method when the user gets to this page. The solution we’ll use for that is the created()
lifecycle hook. We’ll use this hook because we need to make the API call and populate our data (like images
) before the template is rendered. created()
let’s us run this code right at the point we desire. And the reason we want to do all this before the template is rendered is because otherwise Vue will try to build the page with data that doesn’t exist yet, resulting in an error.
Just after the props
section in SearchResults.vue
, add the created()
hook and a this.search()
call:
<script>
export default {
...,
props: {
tag: String
},
created() {
this.search();
},
data() {...}
...
}
</script>
Now when you search from the homepage, you’ll get the images again!
But, if you try to perform a new search while you’re already on the search page, the search term and url seem to update, but the images don’t:
Now we need a way monitor when/if the tag
prop in SearchResults
changes. When it does change, we should perform a new API request. Thisis a job for watchers
, which do exactly what we want to do. According to the Vue Documentation, “watchers are most useful when you want to perform asynchronous or expensive operations in response to changing data.” Bingo!
The syntax is as follows:
<script>
export default {
...,
created() {...},
watch: {
tag(value) {
this.search();
}
},
...
}
</script>
Inside the watch
section we create a function with the name of the prop
we want to watch. That function accepts an argument (whatever the new value is for that property). From there you could do other things with it, but we don’t need to because the new value is already set when the user comes to the search results page. All we need to do is call this.search()
again.
Now users can search from the home page as well as the search results page!
Step 3: Refactor ImageCard component
Not everyone has great internet speed when using the web. To improve the experience for users with a slower connection, let’s add a loading state to our ImageCard
component.
To do that we’ll need to add a loading
property to ImageCard.vue
that stores a Boolean value. This value will get passed in from the SearchResults
view as a prop. When it’s true
, it will apply some extra CSS classes to our CardImage
html. When it’s false
, those classes will not be added.
Step 3.1: Template updates
Update ImageCard.vue
to have the following template:
Throughout the template we are applying the conditional class skeleton
when loading
is true
:
:class="{ skeleton: loading }"
Inside the curly brackets, skeleton
is the class string and loading
is the prop we’re using to decide whether the class gets added or not.
Notice in the above template that we’re also simplifying the template strings for each piece of image data: {{ image.title }}
is now {{ title }}
. This means we’ll be making some computed properties in a minute.
Step 3.2: More detailed props
Next, update the props
option of ImageCard
to be:
props: {
image: {
type: Object,
default() {
return {}
}
},
loading: {
type: Boolean,
default: false,
}
The above adds more detailed information about each prop. To add more information, props
has to store an Object, not an Array. Inside the props
object, each key is a prop’s name. That key can then store either just the type of the prop
, such as loading: Boolean
, or we can go even further, specifying a type and default value. While you don’t have to do any of this when you’re rapidly prototyping, it’s good practice in a real application to make this effort, as it can prevent things from breaking in the future.
Above we’re saying the image
prop will be of type Object
and return an empty object by default. loading
will be a Boolean
with false
as its default.
Step 3.3: New computed properties
Now it’s time to add those computed properties I mentioned. Inside ImageCard.vue
add a computed
section with the following computed properties:
Most of these are pretty straight forward:
title()
returns the image title if there is one, or a string ofUntitled Image
if there is no titlebyline()
returns a template literal string that includes the image’sownername
attributetimestamp()
uses themoment.js
library we were already importing to format the date. No morefilter
. Just use theformat()
method directly.viewCount()
returns a formatted string for how many times the photo has been viewed. Ifthis.image.views
is 1, the string will be1 view
. Otherwise it will say,# views
.
The slightly trickier one is imageUrl()
. What is line 6 doing? It’s returning an external variable that doesn’t exist yet, TRANSPARENT GIF
. Let’s add it now, just after the moment
import statement, before export default {...}
:
<script>
import moment form 'moment';const TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'export default {...}
</script>
The value of TRANSPARENT_GIF
is a base64 placeholder image that will appear while loading
is true. We use this because the html img
element has to have something as its src
attribute, otherwise its height will collapse. An alternative approach would be to point to a transparent or placeholder PNG in our assets
directory, but the above approach spares us a request and thus is faster. In short, the string is a shortcut for showing a transparent image.
imageUrl()
will return this transparent GIF if loading
is true. If loading
is false, it will return the url of the image to be displayed.
Step 3.4: Skeleton styles
Add the necessary skeleton
styles and glow animation to ImageCard.vue
to make the skeleton cards appear:
Here is the entire ImageCard.vue
file now:
Step 3.5: Modify SearchResults.vue
We’re almost there! We just need to update SearchResults.vue
so it displays our snazzy new loading state. We do this by creating two different ul
's: one for the loading cards, and one for the actual image cards. If loading
is false in SearchResults.vue
, we’ll display the actual image cards. Otherwise, we show an array of 6 imageCard
s with the loading
prop set to true
.
Line 10’s "n in 6"
is just a shorter way to create a loop that will go around 6 times:
<image-card v-for="n in 6" :key="n" :loading="true" />
Lines 24–26 above also add one new computed property, isTagEmpty
, which checks to see if this.tag
is set to null or an empty string. We’ll insert this into our search()
method to prevent users from submitting an empty search, which would cause a bad API request. Place an if
statement to check isTagEmpty
just inside the search
method. If the tag isn’t empty, we’ll make the API request. More detailed error handling could be done here in the future, but this is sufficient for now:
search() {
if (!this.isTagEmpty) {
this.loading = true;
this.fetchImages();
}
},
Recap & Next Steps
That’s it for this segment! It may not feel like we made anything “new”, but we’ve put ourselves in a great position. Our app is now more flexible and DRY, complete with:
- a separate search results page
- a NavBar component that’s usable on all pages
- a fancy new loading state for image cards
Thanks again to Cameron Bothner for contributing many of these improvements, especially the ImageCard
loading state effect!
Next time, in Part 3, we’ll add more functionality to the home page and start calling more methods from the Flickr API. ✌️