Unlocking the Magic of Fine-Grained Reactivity in Qwik

Introduction

cover image

Reactivity is a fundamental concept in modern frontend frameworks, referring to the automatic update of the user interface in response to changes in the application's state. Among the popular reactive frameworks like React, Angular, Svelte, and Vue, Qwik stands out as a fine-grained reactive framework. Although Solid is currently considered the best in terms of executing components only once, Qwik's unique advantage lies in its resumability feature. Unlike hydration in other frameworks, Qwik can serialize the reactivity graph into HTML, reducing the need to download most of the application code on startup. If you want to delve deeper into how Qwik serializes closures into HTML, you can find my previous post on the subject here.

Serialize, Then Resume

Qwik's approach to achieving reactivity involves a three-step process: creating the reactivity graph during SSR/SSG, serializing the graph into HTML, and finally, resuming the reactivity graph on the client side. Let's take a closer look at each step.

1. Creating the Reactivity Graph

In Qwik, the reactivity graph is created during SSR/SSG by executing the components once. Here's an example of explicit reactivity by using useComputed$:

export const ExplicitUseComputed = component$(() => {
  const count = useSignal(0);
  const doubleCount = useComputed$(() => {
    return count.value * 2;
  });
 
  return (
    <>
      <button onClick$={() => count.value++}>count++</button>
      <p>Count: {count.value}</p>
      <p>Doubled Count: {doubleCount.value}</p>
    </>
  );
});

2. Serialization of the Reactivity Graph

Once the reactivity graph is constructed, Qwik serializes it into a JSON format. This allows Qwik to efficiently transmit the reactivity graph as part of the initial HTML response, reducing the need for extensive code downloads on the client-side. Here's an example of the serialized reactivity graph:

{
  "refs": {
    // Reference to the count++ button (for useLexicalScope)
    "7": "1"
  },
  // Context used to create the element contexts for virtual elements
  // In this case, no component QRL exists, so it's empty
  "ctx": {},
  "objs": [
    /* 0, resource of the compute task */ "\u00122",
    /* 1, count signal */ "\u00122",
    /* 2, value of count signal */ 0,
    /* 3, QRL of the compute task */ "\u0002/src/explicitusecomputed_component_doublecount_usecomputed_jxslendxkfe.js#ExplicitUseComputed_component_doubleCount_useComputed_JXSLeNdxKFE[1]",
    /* 4, signal derived for {doubleCount.value} */ "\u00110 @0",
    /* 5, text element {doubleCount.value} */ "#9",
    /* 6, compute task [flag, index, qrl, el, resource] */ "\u0003a 1 3 #6 0",
    /* 7, signal derived for {count.value} */ "\u00111 @0",
    /* 8, text element {count.value} */ "#8"
  ],
  "subs": [
    [
      // objs[0] has a subscription from #9 text element {doubleCount.value},
      // and the value can be derived from objs[4].
      "3 #9 4 #9"
    ],
    [
      // objs[1] has a subscription from the compute task.
      "0 6",
      // objs[1] has a subscription from #8 text element {count.value},
      // and the value can be derived from objs[7].
      "3 #8 7 #8"
    ]
  ]
}

3. Resuming the Reactivity Graph on the Client Side

After the initial HTML response is received on the client-side, Qwik resumes the reactivity graph using the serialized data. This process allows the user interface to be interactive and respond to user interactions just as it would in a traditional reactive application.


reactivity graph

Subscription Management

Qwik employs a runtime tracking technique to maintain connections between various reactive nodes. Whenever a reactive expression is re-run, Qwik rebuilds its dependencies to ensure accurate reactivity. The subscription management in Qwik consists of two primary components:

  • Invoke Context: This component is responsible for keeping track of any running reactions or derivations.

  • Subscription Manager: This component handles the creation and removal of subscriptions as needed during the reactivity process.

Running Tasks

In addition to the core primitive useSignal(), Qwik provides other utilities like useComputed$(), useResource(), and useTask(). When these tasks are invoked, Qwik clears the subscriptions using the subscription manager and creates a new invoke context with a subscriber before executing the tasks. Here's a snippet illustrating the process:

// useComputed$()
const iCtx = newInvokeContext(locale, hostElement, undefined, 'ComputedEvent');
iCtx.$subscriber$ = [0, task];
 
// useResource() and useTask() only track those wrapped by track(...)
const track: Tracker = (obj: any, prop?: string) => {
  if (isFunction(obj)) {
    const ctx = newInvokeContext();
    ctx.$renderCtx$ = rCtx;
    ctx.$subscriber$ = [0, task];
    return invoke(ctx, obj);
  }
  // ...
};
 
const { $subsManager$: subsManager } = containerState;
const taskFn = task.$qrl$.getFn(
  // current context
  iCtx,
  // will be executed before running the task
  () => {
    subsManager.$clearSub$(task);
  }
);

Rendering Components

When rendering a component, Qwik clears the subscriptions using the subscription manager and creates a new invoke context with a subscriber before executing the component. This ensures that components respond correctly to changes in state. Here's an example:

// In renderComponent, clear the subscriptions first
containerState.$subsManager$.$clearSub$(hostElement);
 
// In executeComponent, create the componentFn with a new invoke context
const iCtx = newInvokeContext(locale, hostElement, undefined, RenderEvent);
iCtx.$subscriber$ = [0, hostElement];
const componentFn = componentQRL.getFn(iCtx);

Updating the Global Context through invoke()

To keep track of the running reaction, Qwik creates the context stack using the invoke() function. This ensures that the correct context is maintained during the execution of reactive expressions. Here's how invoke() is used:

export function invoke<ARGS extends any[] = any[], RET = any>(
  this: any,
  context: InvokeContext | undefined,
  fn: (...args: ARGS) => RET,
  ...args: ARGS
): RET {
  const previousContext = _context;
  let returnValue: RET;
  try {
    _context = context;
    returnValue = fn.apply(this, args);
  } finally {
    _context = previousContext;
  }
  return returnValue;
}

Creating New Subscriptions

When reading from a signal or proxy in Qwik, the framework attempts to get the subscriber from the current invoke context and then adds it to its local subscription manager. This ensures that the reactivity chain remains correct. Here's how Qwik handles it:

export class SignalImpl<T> extends SignalBase implements Signal<T> {
  get value() {
    // Get the subscriber from the invoke context (e.g., [0, task], [0, hostElement], ...)
    const sub = tryGetInvokeContext()?.subscriber;
    if (sub) {
      // Add to the local subscription manager of this signal
      this[QObjectManagerSymbol].$addSub$(sub);
    }
    return this.untrackValue;
  }
}
 
export class ReadWriteProxyHandler implements ProxyHandler<TargetType> {
  get(target: TargetType, prop: string | symbol): any {
    const invokeCtx = tryGetInvokeContext();
    let subscriber: Subscriber | undefined | null;
 
    // Get the subscriber from the invoke context (e.g., [0, task], [0, hostElement], ...)
    if (invokeCtx) {
      subscriber = invokeCtx.subscriber;
    }
 
    if (subscriber) {
      const isA = isArray(target);
      // Add to the local subscription manager of this proxy
      this.$manager.$addSub$(subscriber, isA ? undefined : prop);
    }
    return recursive ? wrap(value, this.$containerState) : value;
  }
}

Structural Changes

While Qwik excels at fine-grained reactivity, there are certain limitations when it comes to describing structural changes within Signals. Structural changes involve adding or removing DOM nodes, and currently, Qwik cannot directly describe these changes within the reactive context. As a result, in scenarios where structural changes occur, Qwik is forced to download and re-execute the relevant component to maintain the reactivity graph.

Let's explore a couple of examples to better understand the challenges and possible workarounds.

Example: <Resource />

Consider the following example where the ExplicitUseResource component utilizes the useResource$() hook to fetch data and render a list of Pokemon based on the count Signal:

export const ExplicitUseResource = component$(() => {
  const count = useSignal(1);
 
  const pokemonList = useResource$(async ({ track }) => {
    track(() => count.value);
    await new Promise((resolve) => setTimeout(() => resolve(null), 2000));
    return Array(count.value)
      .fill(null)
      .map((_, idx) => `pokemon-${idx + 1} 🐙`);
  });
 
  return (
    <>
      <button onClick$={() => count.value++}>count++</button>
      <p>Count: {count.value}</p>
      <Resource
        value={pokemonList}
        onPending={() => <h2>loading...</h2>}
        onResolved={(pokemons) => <h2>{pokemons}</h2>}
      />
    </>
  );
});

In this case, because the number of Pokemon displayed in the list changes based on the count Signal, Qwik faces difficulty in describing the structural changes effectively. As a result, Qwik is required to download the component QRL of ExplicitUseResource whenever the count Signal changes, leading to re-execution of the component.

Example: Remove Component conditionally

Now, consider the following example where the RemoveComponentConditionally component conditionally renders the Display component based on the count Signal:

export const RemoveComponentConditionally = component$(() => {
  const count = useSignal(0);
 
  return (
    <>
      <button onClick$={() => count.value++}>count++</button>
      <p>Count: {count.value}</p>
      {count.value < 3 && <Display count={count} />}
    </>
  );
});
 
export const Display = component$<{ count: Signal<number> }>(({ count }) => {
  return <p>Count: {count.value}</p>;
});

In this case, when the value of count is less than 3, the Display component is rendered. However, because Qwik cannot fully describe the conditional rendering within the reactivity graph, it is forced to download the component QRL of RemoveComponentConditionally whenever the value of count changes.

Conclusion

In conclusion, Qwik stands as a fine-grained reactive framework similar to SolidJS, it directly updates the DOM upon changes in the application's state. This level of reactivity ensures a seamless and highly responsive user interface. While there're still some cases where Qwik needs to execute the whole component, it excels at delivering precise and surgical reactivity, limiting updates to only the necessary portions of the DOM.

However, the true magic of Qwik lies in its ingenious approach to reactivity. Fine-grained reactivity necessitates that all components execute at least once to establish the reactivity graph. Qwik brilliantly capitalizes on the fact that components have already been executed on the server during SSR/SSG. By serializing this reactivity graph into HTML, Qwik bestows the client with the extraordinary ability to bypass the initial "execute the world to learn about the reactivity graph" phase entirely. This unique feature is known as "resumability."

Thanks to resumability, the client can launch the application instantaneously without the need for rehydration and component code downloads. The reactive components are already primed and ready, precisely as they were on the server. This translates into an instant startup experience, where users can swiftly interact with the application without delay.

Reference