rfcs icon indicating copy to clipboard operation
rfcs copied to clipboard

v-await / v-then / v-catch directives

Open CapitaineToinon opened this issue 3 years ago • 9 comments

What problem does this feature solve?

I've been using svelte for a while and I completely fell in love with await block. In svelte, if you have a function that returns a promise, it can be very easily awaited directly in the template and it's really amazing to use.

<script>
	let promise = getUser();

	async function getUser() {
		const resp = await fetch('https://www.example.com/api/users/1')
		return resp.json();
	}
</script>

{#await promise}
	<p>...waiting</p>
{:then number}
	<p>The number is {number}</p>
{:catch error}
	<p style="color: red">{error.message}</p>
{/await}

And I think something similar would be awesome in Vue 3. I've always wanted a feature like this in Vue 2 and while there are solutions like handing everything yourself manually or using a plugin like vue-wait, it never felt like a nice solution. Look at an example on how you could currently handle such a case.

<template>
  <div v-if="error">
    {{ error.message }}
  </div>
  <div v-else-if="user === undefined">
    Loading...
  </div>
  <div v-else>
    Hello, {{ user.name }}!
  </div>
</template>

<script>
export default {
  data: () => ({
    user: undefined,
    error: null,
  }),
  methods: {
    async getUser() {
      this.user = undefined;
      this.error = null;

      try {
        const resp = await fetch('https://www.example.com/api/users/1')
        this.user = resp.json();
      } catch (error) {
        this.error = error;
      }
    }
  },
  mounted() {
    this.getUser();
  }
};
</script>

What does the proposed API look like?

Using await, then and catch

<template>
  <div v-await="getUser()">
    Loading user...
  </div>
  <div v-then="{ user }">
    Hello, {{ user.name }}!
  </div>
  <div v-catch="{ error }">
    {{ error }}
  </div>
</template>

Only using await and then on a single element

<template>
  <div v-await="getUser()" v-then="{ user }">
    Hello, {{ user.name }}!
  </div>
  <div v-catch="{ error }">
    {{ error }}
  </div>
</template>

The catch could be optional

<template>
  <div v-await="getUser()">
    Loading user...
  </div>
  <div v-then="{ user }">
    Hello, {{ user.name }}!
  </div>
</template>

CapitaineToinon avatar Aug 09 '20 22:08 CapitaineToinon

What if we have multiple promises to v-await and we want to show them with v-then but with a custom order ?

Mdev303 avatar Aug 10 '20 12:08 Mdev303

I don't think this is particularly useful because it only works in scenarios where you want to either display a loading placeholder or the content itself. But in practice, you still want to display parts of the UI while loading, replacing only portions of the UI with placeholders or spinners. With this approach there is no granularity. It works well on small demos but in real use cases, you rarely do it that way. Specially given the trend of placeholders that exists everywhere right now (https://github.com/michalsnik/vue-content-placeholders) that benefit from reusing an existing layout

At the end, this is just another syntax that can be worked out in plugins like https://github.com/posva/vue-promised or https://github.com/posva/vue-compose-promise

posva avatar Aug 10 '20 13:08 posva

@MaazDev What if we have multiple promises to v-await and we want to show them with v-then but with a custom order ?

The content if each v-await, v-then and v-catch would be conditionally rendered like a v-if so the content of a v-then would only be processed once the promise is resolved which would allow you to nest other v-await in the v-then if desired

<template>
    <div v-await="getUser()">
        Loading user...
    </div>
    <div v-then="{ user }">
        <div v-await="getBlogs(user.id)">
            Loading blogs...
        </div>
        <div v-then="{ blogs }">
            <ul>
                <li v-for="blog in blogs" :key="blog.url">
                    <a :href="blog.url">{{ blog.title }}</a>
                </li>
            </ul>
        </div>
        <div v-catch="{ error }">
            {{ error }}
        </div>
    </div>
    <div v-catch="{ error }">
        {{ error }}
    </div>
</template>

If there are too many nesting required, nothing prevents you from doing a bigger promise that returns all the data you need and only wait for that one.

<template>
    <div v-await="getData()">
        Loading user and blogs...
    </div>
    <div v-then="{ data }">
        <h1>{{ data.user.name }}</h1>
        <ul>
            <li v-for="blog in data.blogs" :key="blog.url">
                <a :href="blog.url">{{ blog.title }}</a>
            </li>
        </ul>
    </div>
    <div v-catch="{ error }">
        {{ error }}
    </div>
</template>

<script>
export default {
    methods: {
        async getData() {
            const user = await getUser();
            const blogs = await getBlogs(user.id);

            return {
                user,
                blogs
            }
        }
    }
}
</script>

With this example you would need to access data.user and data.blogs though which feels pretty ugly. In Svelte you can destructure directly in the then block but I don't know if a syntax like this would be possible with Vue. Thoughts?

<script>
function getData() {
        // logic here
        return [user, blogs]
}
</script>

{#await getData()}
	<!-- promise is pending -->
	<p>waiting for the promise to resolve...</p>
{:then [user, blogs]}
	<!-- promise was fulfilled, can now use user and blogs variables -->
{/await}

@posva I don't think this is particularly useful because it only works in scenarios where you want to either display a loading placeholder or the content itself. But in practice, you still want to display parts of the UI while loading, replacing only portions of the UI with placeholders or spinners.

Of course and since this proposal would only add directives, you could use those on selected elements of the UI only. You're later talking about placehoders and this could work very well compined with v-await. Consider this example where we already have the user object but need to fetch the user's profile picture, we could display all the currently available data while using the await directive to show a placeholder for the image in the meantime.

<template>
    <div class="user-card">
        <p>{{ user.name }}</p>

        <ul>
            <!-- displaying some already fetched data -->
            <li v-for="social in user.links" :key="social.id">
                <a :href="social.url">{{ social.name }}</a>
            </li>
        </ul>

        <!-- but displaying a loading placeholder for other parts of the UI -->
        <div v-await="getUserImage(user.id)">
            <content-placeholders-heading :img="true" />
        </div>
        <div v-then="{ img }">
            <img :src="img" :alt="`${user.name}'s profile picture`">
        </div>
    </div>
</template>

With this approach there is no granularity.

Maybe I wasn't clear enough in my first example but I picture this working very similarly to how v-if / v-else-if / v-else currently work. So for example you would need to the using a v-then alone could trigger and error such as 'v-then' directives require being preceded by the element which has a 'v-await' directive. and so on.

At the end, this is just another syntax that can be worked out in plugins [...]

Yes I agree that there are already solutions available but I thought this proposal would be a nice syntaxic sugar that would benefit Vue as a whole.

CapitaineToinon avatar Aug 10 '20 13:08 CapitaineToinon

Consider this example where we already have the user object but need to fetch the user's profile picture, we could display all the currently available data while using the await directive to show a placeholder for the image in the meantime.

It's not what I'm talking about. I'm talking about fetching the whole content and still displaying the main layout of the app while waiting for that content like airbnb, Vercel, sometimes Github, Facebook, instagram. If the v-await is at the top nothing is displayed until you get the data. or you duplicate the layout:

 <div v-await="getUser(user.id)">
layout with placeholders
        </div>
        <div v-then="{ img }">
same layout with the actual information
        </div>

posva avatar Aug 10 '20 13:08 posva

If the v-await is at the top nothing is displayed until you get the data. or you duplicate the layout

Yes but that would be the developer's fault, not a design flaw of the feature. Consider this example using v-if, my app is basically empty until the user had loaded. That sucks I agree but hey that's how I wrote my app so shame on me.

<template>
    <div v-if="user">
        <h1>{{ user.name }}</h1>
    </div>
    <!-- app is unavailable while data is loading but hey that's my fault -->
</template>

<script>
export default {
    data: () = ({
        user: undefined
    })
    methods: {
        async getUser() {
            // await logic that is very slow
            return user;
        }
    },
    async mounted() {
        this.user = await this.getUser()
    }
}
</script>

So yes v-await could be use badly but that's true for everything. Edit: unless I still don't understand what specific case you're talking and if so I apologize.

CapitaineToinon avatar Aug 10 '20 14:08 CapitaineToinon

Your examples are too small so they will never highlight the problem. I think what @posva is trying to get at is that if the data you are fetching is meant to be displayed in different or multiple parts of the UI, you will be forced to hoist the v-await directive to the highest common element. Which could lead to template duplication, and large parts of the UI not being rendered until the promise resolves.

Here's part of an imaginary profile page of a user.

<div>
  <skeleton type="avatar" :active="!user">
    <avatar :src="user.avatar" />
  </skeleton>
  <grid>
    <row>
      <skeleton type="paragraph" :active="!user">
        <p>{{user.about}}</p>
      </skeleton>
    </row>
	...
  </grid>
  ...
</div>

With v-await you'd have to put the directive on the top div, or you'd be making multiple identical calls to the backend (and it would be way more verbose). Thus you'd have to duplicate the layout.

<div v-await="getUser()">
  <skeleton type="avatar" />
  <grid>
    <row>
      <skeleton type="paragraph"/>
    </row>
    ...
  </grid>
  ...
</div>
<div v-then>
  <avatar :src="user.avatar"/>
  <grid>
    <row>
      <p>{{user.about}}</p>
	</row>
    ...
  </grid>
  ...
</div>
<div v-catch>
  error message, or maybe you'd still want the layout which means another duplication
</div>

nekosaur avatar Aug 13 '20 09:08 nekosaur

Thanks for explaining, now I understand the problem and @posva being the author of vue-promised, I assume that's what his combined slot is meant to solve.

I still think the v-await / v-then / v-catch could remove a lot of boilerplate for more simple cases though where skeletons aren't needed. I personally never use skeletons because I don't have apps on a scale that requires it but I also don't really know how popular that practice is.

Maybe another v-promise directive behaving like the combined slot from vue-promised could be a solution but in that case I think it's getting too specific and the user should just handle the case manually or use a 3rd party library.

CapitaineToinon avatar Aug 13 '20 09:08 CapitaineToinon

Exactly, using a 3rd party library yields the same result and expressiveness than a native feature that would only be used in simple cases. That's why I don't think this is needed

posva avatar Aug 13 '20 10:08 posva

@CapitaineToinon I don't understand why you think the same thing can't be achieved with v-if and v-else. In that example you can you can just use a v-else for when it's loading. Even better, you could have an explicit loading variable. At the start of the function set the data and error to null and loading to true, once it's finished loading you can set loading to false and set the data and error.

coolCucumber-cat avatar Dec 14 '23 13:12 coolCucumber-cat