DEV Community

Florian Reuschel
Florian Reuschel

Posted on

(An Approach to) Vue.js Template Variables

The Problem

From time to time, I have the need to temporarily store the results of a method call in Vue templates. This is particularly common inside loops, where we cannot easily use computed properties.

Basically what we'd want to avoid is this:

<!-- List.vue -->
<ul>
  <li v-for="id in users" :key="id">
    <img :src="getUserData(id).avatar"><br>
    🏷️ {{ getUserData(id).name }}<br>
    🔗 {{ getUserData(id).homepage }}
  </li>
</ul>
Enter fullscreen mode Exit fullscreen mode

Common Solutions

We could describe this problem as "computed properties with arguments", and it already has some established solutions out there:

Outsource Components

The pretty much canonical way is done through refactoring: We could outsource the <li> items into their own <ListItem> component.

That component would receive the id as a prop and store the according metadata in a computed property which is then cached by Vue until it needs to be re-evaluated.

<!-- List.vue -->
<ul>
  <ListItem v-for="id in users" :key="id" :id="id" />
</ul>

<!-- ListItem.vue -->
<li>
  <img :src="metadata.avatar"><br>
  🏷️ {{ metadata.name }}<br>
  🔗 {{ metadata.homepage }}
</li>
Enter fullscreen mode Exit fullscreen mode

However, this approach can be pretty tedious to write and maintain: all pieces of data we need inside each list item have to be passed down to the <ListItem> as props.

It can also be hard to follow as a reader — particularly if the <ListItem> component is very small. Then it may easily contain four lines of template code followed by 25 lines of props definition boilerplate.

Memoize Method Results

We could also memoize the results of getUserData().

However this can be tedious to implement as well, it usually only works with serializable input data — and of all approaches, adding another layer of memoization on top of Vue feels like suiting the Vue way™ the least.

My Approach

For my projects, I like to use another (less obvious, and AFAICT less common) approach: I create a helper component I call <Pass>.

It's really, really tiny:

const Pass = {
  render() {
    return this.$scopedSlots.default(this.$attrs)
  }
}
Enter fullscreen mode Exit fullscreen mode

Basically this is a placeholder component which does not render a DOM element itself but passes down all props it receives to its child.

So, let's rewrite our list with the <Pass> helper:

<!-- List.vue -->
<ul>
  <Pass v-for="id in users" :key="id" :metadata="getUserData(id)">
    <li slot-scope="{ metadata }">
      <img :src="metadata.avatar"><br>
      🏷️ {{ metadata.name }}<br>
      🔗 {{ metadata.homepage }}
    </li>
  </Pass>
</ul>
Enter fullscreen mode Exit fullscreen mode

This will only evaluate getUserData() once: when <Pass> is rendered. Nice and clean, isn't it?

Also, here's a CodeSandbox where you can fiddle around with the example I described:

Caveats

To be fully honest, there are a few drawbacks to this approach:

  • The helper component utilizes a scoped slot for passing data. This means, <Pass> can only have exactly one child component.
  • Another limitation to this approach is that the markup injected into the slot must render a real DOM node. We can't just set the slot-scope on something like a <template>.

That's it. I hope this helps simplify your Vue templates!

Top comments (20)

Collapse
 
pbastowski profile image
pbastowski

There is a simpler way to achieve this, which is probably even less known, without the use of an extra component and it goes like this:

<li v-for="id in users" :key="id" :set="item = getUserData(id)">
    <img :src="item.avatar" /><br />
    🏷️ {{ item.name }}<br />
    🔗 {{ item.homepage }}
</li>
Enter fullscreen mode Exit fullscreen mode

For those that may now be thinking, "Hey, I didn't know there was an undocumented :set in Vue", there isn't. What I'm doing here is relying on the fact that Vue will evaluate the JavaScript of any bound attributes and I just chose to invent an attribute called :set.

As a reminder, a bound attribute is one that is prefixed with a : or v-bind:. The JavaScript expression inside the double quotes will be evaluated in the context of the current component and the item variable will still be available outside of the v-for in which it is being set. So, it's not a local variable, as such, rather, it's a locally assigned component scope (non-reactive) variable.

Do note that this attribute does not have to be declared in your data component first. However, if you don't declare it, it will not be reactive. In the example above it does not matter to me if it's reactive, but it's something to keep in mind if you use this pattern

Here is a fork of Florian's code (thank's Florian) showing the pattern at work.
codesandbox.io/s/6nwyw3zzwz

Collapse
 
matthewdean profile image
Matthew Dean

This may work in JS but TypeScript can't make heads or tails of this. The referenced function will be marked as un-used, causing a TS error, and the unknown variable will also be marked as unknown. You can define the var in the <script> block, but it will still leave the previous error.

Collapse
 
matthewdean profile image
Matthew Dean

A better approach may be to use a WeakMap in the getUserData function. Like:

const itemFromId = new WeakMap()
function getUserData(id) {
  let item = itemFromId.get(id)
  if (item) return item
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Repeated calls to getUserData within the loop, then, will instantly return the item, and once we're out of that iteration of the loop, the cached result will be garbage collected.

I didn't put in TypeScript annotations but TS handles this approach much better as well.

Collapse
 
loilo profile image
Florian Reuschel

This is definitely a clever hack. A little bit dirty, but absolutely clever. 😁

Collapse
 
pbastowski profile image
pbastowski

Thanks. I would like to add, though, this is neither dirty nor a hack.

Vue does allow us to create local variables on this and just cautions that they won't be reactive (I read it somewhere in the docs).

As for setting values, well, it's just like an event handler, where one would do exactly the same:, for example: @click="item=$event"

I know it feels like a hack, at first looks, because it's too simple, but it's legit.

Perhaps, I cheated a bit by using an attribute named :set ;)

Thread Thread
 
loilo profile image
Florian Reuschel

I think my perception of this as a "hack" mostly refers to "using assignment expressions as attributes". It just doesn't feel right (to me, personally). Sure, it's a thing we've done forever in on* event attributes, but at least the semantics of events do match this better than the semantics of vanilla attributes.

That's pretty much the same as let foo; while (foo = expression) { /* use foo */ } — it's a thing that is frowned upon by many readers and linters, but it's used because the clean way would be annoyingly verbose.

Thread Thread
 
pbastowski profile image
pbastowski

I hear you.

Collapse
 
blocka profile image
Avi Block

How does this solve the fact that the call is not memoized?

Collapse
 
pbastowski profile image
pbastowski

It doesn’t. It just helps to avoid using deeply nested object references or making calls to the same function several times. You could use a computed, without a parameter, if you want caching.

Thread Thread
 
blocka profile image
Avi Block

Hmmm...no you can't use a computed here. It's in a loop. That was the whole point here.

Thread Thread
 
pbastowski profile image
pbastowski • Edited

Indeed, you’re right. A normal computed wouldn’t have access to the v-for parameter here and a parametrized computed wouldn’t be cached.
If you need caching then you will need a different technique.
You could declare a data variable and then assign the v-for iterator to it, like I have shown in my example. In this way, a computed would have access to that data variable with the iterator’s value. Would this work for you?

Collapse
 
bponomarenko profile image
Borys Ponomarenko • Edited

My way would be to create computed property with stored results of getUserData function execution:

computed: {
    usersWithData() {
        return this.users.map(id => ({ id, ...this.getUserData(id) }));
    }
}
Enter fullscreen mode Exit fullscreen mode

After that is can simply be used like:

<li v-for="user in usersWithData" :key="user.id">
    <img :src="user.avatar" /><br />
    🏷️ {{ user.name }}<br />
    🔗 {{ user.homepage }}
</li>
Enter fullscreen mode Exit fullscreen mode

More Vue-ish as for me.

Collapse
 
loilo profile image
Florian Reuschel • Edited

Yes, this is the obvious one and should always be preferred where applicable.
Template variables are a better (as in "more ergonomical") fit when you don't need to process all the array items and don't want to duplicate logic.

Collapse
 
alin_air profile image
Alin Andrei

I'm a newbie when talking about Vue - well, programming in general, but your solution makes sense. I'll try to make use of it in an Angular based app I'm working on at office, where I've encountered the need of keeping some computed values close. Thanks for sharing!

Collapse
 
loilo profile image
Florian Reuschel

Glad it helped. The approach was heavily inspired by React's render props. I have no clue though whether it's applicable to the Angular world.

Collapse
 
alin_air profile image
Alin Andrei

I guess we'll find out 😁

Collapse
 
smolinari profile image
Scott Molinari

How come the user data isn't "gotten" up front? I mean, creating calls for data in a loop (even in a program) is a bad pattern to begin with, isn't it?

Scott

Collapse
 
loilo profile image
Florian Reuschel

The user data thing is a completely made up example, I didn't come up with something better that's pragmatic and also immediately "clicks" with the reader.

But yeah, if you mean network requests with "calls for data", that would not necessarily be a bad idea in a loop, but they certainly do not belong inside a template. In a real world example, the kind of data to store usually is some calculation or data structure creation you don't need in each iteration of the loop.

You could certainly create all needed data upfront and move the logic to other places (e.g. by memoizing or refactoring to additional components, as mentioned in the post) but to me it actually happens quite a lot that such an approach might be "cleaner" but is not worth sacrificing the amount of clarity and ease of use.

Collapse
 
smolinari profile image
Scott Molinari

Yup. It's a bad example. Glad you agree. User data for the front end should not be in different places. In other words, there is no reason to have an array of users to then have to get the data to fill that array in a loop. That gathering of data should be done before the data reaches the loop.

If you pass anything to child components being looped over in a v-for, that passed data should be relatively small bits of data already "gotten". Or it can be data that can be broken down through the hierarchy of components. Most definitely each smaller component should NOT be calling for more data. That's sidestepping one-way data flow.

So, I'll be so bold to say, you probably couldn't find a practical use case, because there isn't one, because trying to call for data from passed in props or data is simply a poor pattern. If you are finding you need to do that, you are somehow starting off with an incorrect mental model of SFCs, component hierarchy and proper reactive data flow.

Scott

Collapse
 
johnlouiebinas profile image
John Louie Biñas

This can be useful to prevent v-for and v-if in one element.