Should You use Tasks in Qwik?

Should You use Tasks in Qwik?

Published:

We should be cautious with using tasks in Qwik. Especially, heavy use of useVisibleTask$() can eliminate the benefits that we gain from using Qwik in the first place.

Cover image

In my previous article How to Learn Qwik FAST! , we covered the topic tasks on a shallow level. I ended with the point that we should be careful with the use of useVisibleTask$() . This article addresses some issues that are involved in using tasks and what we can do instead.

Before we start, let’s make sure we are on the same page.

Tasks are a straight-forward way to do setup work or trigger side effects on state changes within components. They are the counterpart of useEffect() in React.

Performance of Tasks

Qwik is fast because it aims to delay execution of JavaScript as much as possible to optimize our page for short Time to interactive (TTI) .

For this, Qwik guides us to write splittable code. Possible split points are marked with a trailing $ sign in function names and function properties.

Both task factories useTask$() and useVisibleTask$() mark a split point with their $ -suffix, and the task implementation may be lazily loaded by the Qwik loader in the browser.

This is great! But there are still some things that we developers need to consider. It is still possible to write code that loads split modules before they are actually required.

To use lazy loading well, we need to know when the Qwikloader loads modules in the browser.

useTask$() performance

Splitting out a task’s implementation can result in performance gains in three scenarios.

  1. When used without tracking, our task runs only once on the server before rendering. It is a good idea to split out the implementation during SSR because it would be dead code for the client.
  2. If we track() state, this state might never change, based on the way the user interacts with the component. So the implementation could still never be loaded, and it was a good choice to not ship the code eagerly.
  3. If tracked state changes during the component lifecycle, the code will be loaded by the Qwikloader. If the state change does not happen immediately after rendering in the browser, we still hit our goal to minimize our TTI.

So, in general, useTask$() is a good fit for building instant-on pages. This does not mean that it’s always the best choice and its capabilities are limited. But more on this later.

useVisibleTask$() performance

If we use useVisibleTask$() , by default the implementation is lazily loaded and executed when the component, i.e., its first rendered DOM element, becomes visible.

If the component is not initially placed within the viewport, this kind of task is a good fit for reducing TTI.

All in all, there are some performance and code quality concerns related to tasks, which we want to avoid.

Luckily, Qwik offers some other ways to trigger side effects. We should be aware of them, as they sometimes allow us to avoid eager loading of functions or improve the quality of our code.

Let’s now take a look at some common use cases where we may find better solutions in Qwik’s toolbox.

Alternatives for Event Handling

Visible tasks can used for handling events that are related to the current component. Those may be local events emitted by the component or its children, but it can also be global events like scroll or visibilitychange that have to be captured on the window or document object.

Let’s implement a scroll spy, for example.

With useVisibleTask$() , we can create a scroll spy component like this:

import {
  component$,
  useSignal,
  useVisibleTask$,
} from "@builder.io/qwik";
import { ScrollIndicator } from "~/components/ScrollIndicator";

export default component$(() => {
  const scrolled = useSignal(0);

  // eslint-disable-next-line qwik/no-use-visible-task
  useVisibleTask$(({ cleanup }) => {
    const onScroll = () => {
      scrolled.value =
        scrollY /
        (document.body.scrollHeight - innerHeight);
    };
    window.addEventListener("scroll", onScroll);
    cleanup(() => {
      window.removeEventListener("scroll", onScroll);
    });
  });
  return <ScrollIndicator scrolled={scrolled.value} />;
});

In this example, we use the useVisibleTask$() to add a scroll event listener to the window . When the component is unmounted, we have to remove the listener again.

Note: As you can immediately tell from the eslint rule that is enabled with Qwik’s project scaffolding, we are discouraged from using useVisibleTask$() in the first place.

This is how one could naively implement a scroll spy.

Not only is this complicated and a potential source of errors, if we forget to remove the event listener, but Qwik also loads the task eagerly.

Event handling via Function properties

Let’s now use the window:onScroll$ property instead.

import { component$, useSignal } from "@builder.io/qwik";
import { ScrollIndicator } from "~/components/ScrollIndicator";

export default component$(() => {
  const scrolled = useSignal(0);
  return (
    <ScrollIndicator
      window:onScroll$={() => {
        scrolled.value =
          scrollY /
          (document.body.scrollHeight - innerHeight);
      }}
      scrolled={scrolled.value}
    />
  );
});

The task implementation is loaded only when the first scroll event is triggered for the first time, unlike the visible task solution in this example. As a bonus, Qwik will automatically remove the global event listener on window when this component gets unmounted.

Event handling via useOn[Window|Document]()

A more flexible, imperative way to register a global event listener within a component in Qwik is with a useOn[Window|Document]() hook.

import {
  $,
  component$,
  useOnWindow,
  useSignal,
} from "@builder.io/qwik";
import { ScrollIndicator } from "~/components/ScrollIndicator";

export default component$(() => {
  const scrolled = useSignal(0);
  useOnWindow(
    "scroll",
    $(() => {
      scrolled.value =
        scrollY /
        (document.body.scrollHeight - innerHeight);
    }),
  );
  return <ScrollIndicator scrolled={scrolled.value} />;
});

The behavior is the same as in the previous example with the property function. However, this approach is more flexible in that you don’t have to know the event name at compile time. The drawback is that you have to wrap your function explicitly with $() because useOnWindow() does not have a trailing $ . This is because the first argument isn't the function, but the name of the event, and the Qwik optimizer only splits at the first argument.

Alternatives for loading data on the client

Occasionally, we want to fetch data from an API in the browser. We could do this with a visible task in Qwik.

Let’s take a look at a naive autocomplete search implementation, for example.

import {
  component$,
  useSignal,
  useVisibleTask$,
} from "@builder.io/qwik";
import { search } from "~/api/search";

export default component$(() => {
  const query = useSignal("");
  const loading = useSignal(false);
  const results = useSignal<string[]>([]);

  // eslint-disable-next-line qwik/no-use-visible-task
  useVisibleTask$(async ({ track }) => {
    track(() => query.value);
    if (!query.value) {
      results.value = [];
      return;
    }
    loading.value = true;
    results.value = await search(query.value);
    loading.value = false;
  });
  return (
    <div>
      <input
        type="text"
        onInput$={(e) =>
          (query.value = (
            e.target as HTMLInputElement
          ).value)
        }
      />
      {loading.value ? (
        <div>Loading...</div>
      ) : (
        <ul>
          {results.value.map((result, idx) => (
            <li key={idx}>{result}</li>
          ))}
        </ul>
      )}
    </div>
  );
});

While this solution works, the task implementation will be loaded as soon as the input field becomes visible.

Using useTask$()

When we want to use useTask$() , we have to keep in mind that it blocks rendering until it completes. So we either have to drop the loading indicator or we don’t wait for the promise.

import {
  component$,
  useSignal,
  useTask$,
} from "@builder.io/qwik";
import { search } from "~/api/search";

export default component$(() => {
  const query = useSignal("");
  const loading = useSignal(false);
  const results = useSignal<string[]>([]);

  useTask$(({ track }) => {
    track(() => query.value);
    if (!query.value) {
      results.value = [];
      return;
    }
    loading.value = true;
    search(query.value).then((res) => {
      results.value = res;
      loading.value = false;
    });
  });
  return (
    <div>
      <input
        type="text"
        onInput$={(e) =>
          (query.value = (
            e.target as HTMLInputElement
          ).value)
        }
      />
      {loading.value ? (
        <div>Loading...</div>
      ) : (
        <ul>
          {results.value.map((result, idx) => (
            <li key={idx}>{result}</li>
          ))}
        </ul>
      )}
    </div>
  );
});

This solution works for this particular example. The task implementation is only loaded on the first input event. It is still quite cumbersome to handle the current loading state (and error state in more robust implementations).

We would run into issues with this approach, as soon as we want to prefetch the data during SSR. We then would have to await the promise in the task, and as such we would have to drop the loading indication.

Loading data with useResource$()

For async data consumption, Qwik provides us with a useResource$() hook. Using this for the autocomplete search from above would look like this:

import {
  Resource,
  component$,
  useResource$,
  useSignal,
} from "@builder.io/qwik";
import { search } from "~/api/search";

export default component$(() => {
  const query = useSignal("");
  const results = useResource$(async ({ track }) => {
    track(() => query.value);
    if (!query.value) return [];
    return search(query.value);
  });
  return (
    <div>
      <input
        type="text"
        onInput$={(e) =>
          (query.value = (
            e.target as HTMLInputElement
          ).value)
        }
      />
      <Resource
        value={results}
        onResolved={(res) => (
          <ul>
            {res.map((result, idx) => (
              <li key={idx}>{result}</li>
            ))}
          </ul>
        )}
        onPending={() => <div>Loading...</div>}
        onRejected={(err) => (
          <div>Error: {err.message}</div>
        )}
      />
    </div>
  );
});

This is a more declarative alternative. There are some benefits to using this solution:

So, when it comes to async loading, useResource$() is arguably the best solution.

Alternatives for synchronizing component state

This is not a performance topic, but more of a code quality thing.

We can use useTask$() to synchronize component state.

Let’s take a double-count example: We want to hold a count and a double count (2 * count) signal in sync within our component.

With a task, we can synchronize two signals like this:

import {
  component$,
  useSignal,
  useTask$,
} from "@builder.io/qwik";
import { Button } from "~/components/Button";

export default component$(() => {
  const count = useSignal(0);
  const doubleCount = useSignal(0);

  useTask$(({ track }) => {
    track(() => count.value);
    doubleCount.value = count.value * 2;
  });
  return (
    <Button onClick$={() => count.value++}>
      {count.value} * 2 = {doubleCount.value}
    </Button>
  );
});

What’s good about this solution is that the code for the synchronization is done in a single place. If we change the value of the count signal in another place, doubleCount will be consistent with the new value of count .

Synchronizing within the callback

Alternatively, we could simply update both signals within the same callback.

import { component$, useSignal } from "@builder.io/qwik";
import { Button } from "~/components/Button";

export default component$(() => {
  const count = useSignal(0);
  const doubleCount = useSignal(0);

  return (
    <Button
      onClick$={() => {
        count.value++;
        doubleCount.value = count.value * 2;
      }}
    >
      {count.value} * 2 = {doubleCount.value}
    </Button>
  );
});

While this solution is a bit shorter, it has some flaws:

We would have to synchronize doubleCount in every place where we write to count . Furthermore, in the task example above, we had a more obvious dependency of doubleCount on count .

Synchronizing with useComputed$()

There is also a solution provided by Qwik that is tailored to this problem category: useComputed$()

import {
  component$,
  useComputed$,
  useSignal,
} from "@builder.io/qwik";
import { Button } from "~/components/Button";

export default component$(() => {
  const count = useSignal(0);
  const doubleCount = useComputed$(() => count.value * 2);

  return (
    <Button onClick$={() => count.value++}>
      {count.value} * 2 = {doubleCount.value}
    </Button>
  );
});

As long as we treat doubleCount as read-only, this is probably the best way to synchronize doubleCount with the count signal: We have a central, clear derivation function, the implementation is lazily loaded when count is first touched in the browser, and it’s very concise.

When should we use Tasks?

As discussed above, there are often better solutions than using tasks. So why are they provided by Qwik, and when is it a good idea to use them?

There are still some situations when it is fine to use tasks.

We can use useTask$() with an isBrowser guard for side effects like logging or storing data in localStorage for example.

Occasionally, the execution of useVisibleTask$() is precisely when we need it. Some pages make use of it to animate parts of the page as the user scrolls by.

Let’s make use of the full potential that Qwik provides us to build high performance web pages!

If you want to keep an overview over the topics when learning Qwik, you can simply download the Qwik cheat cheat for free .

In this sense, never stop learning and have a nice day.