Practical guide: build a quiz application with Qwik framework

Introduction

Qwik is a cutting-edge front-end framework designed for building efficient, resumable web applications. It stands out by enabling lightning-fast application startup times on the client side. Qwik achieves speed by downloading and executing only the necessary code for user actions.

Unlike traditional server-side rendered (SSR) frameworks, Qwik operates without the need for a process known as "hydration." In SSR applications, the client-side boot-up involves restoring event listeners, the component tree, and the application state. This restoration process, called hydration, can be resource-intensive as it necessitates the download of all component code associated with the current page.

Qwik's distinct advantage lies in its speed and efficiency. By circumventing the hydration process, it eliminates the need for downloading excessive code, resulting in a responsive and performant client-side experience.

Let’s explore the process of building a quiz application with Qwik.

Setting up the development environment

Before you begin, you should:

  1. Download and install NodeJS
  2. Set up your favorite IDE (vscode recommended)
  3. Create a Netlify account for deployment (optional)

Create your Qwik application

Initialize a new Qwik application using the Qwik CLI. The CLI generates a basic starter template. Open the terminal or command prompt and run the Qwik CLI by executing one of the following commands based on your preferred package manager, whether it’s NPM, Yarn, or PNPM.

npm create qwik@latest
pnpm create qwik@latest
yarn create qwik
bun create qwik@latest

Start the development server:

npm start
pnpm start
yarn start
bun start

Designing the Quiz application

In building the quiz application, we would dive into the most important Qwik concepts such as directory-based routing, and state management.

Create quiz route

Routing in Qwik relies on QwikCity, Qwik’s meta-framework. To learn more about QwikCity, an in-depth dive is available on Bejamas discovery. QwikCity uses directory-based routing, similar to frameworks like Next.js, SvelteKit, SolidStart, or Remix. Files and directories within the src/routes directory define the routing structure of the application. To get started:

Inside your Qwik project's src/routes directory, create a new folder named quiz. Inside the quiz directory, create an index.tsx file.

In the index.tsx file, content is defined using the export default component$(...) syntax.

import { component$ } from "@builder.io/qwik";

export default component$(() => {
  return (
    <div class="section bright">
      <h1>Quiz Page</h1>
    </div>
  );
});

Components serve as the fundamental building blocks of a Qwik application. They are defined using the component$() function and, at the very least, are expected to return a JSX element.

Navigate to http://localhost:5173/quiz/ to see the new page.

Create body of quiz application

In the defined component function, define the JSX markup.

return (
   <>
     {store.user ? (
       <div class={styles["container"]}>
         <h1>Quiz Page</h1>
         <div>
           <h2>
             Question: {store.activeQuestion + 1}
             <span> /{questions.length}</span>
           </h2>
           <h2>Time Left: {store.timer} seconds</h2>
         </div>
         <div>
           {!store.showResult ? (
             <div class={styles["quiz-container"]}>
               <h3>{questions[store.activeQuestion].question}</h3>
               {answers.map((answer, idx) => (
                 <li
                   key={idx}
                   onClick$={() => onAnswerSelected(answer, idx)}
                   class={
                     store.selectedAnswerIndex === idx
                       ? styles["li-selected"]
                       : styles["li-hover"]
                   }
                 >
                   <span>{answer}</span>
                 </li>
               ))}
               {store.checked ? (
                 <button onClick$={nextQuestion} class={styles["btn"]}>
                   {store.activeQuestion === question.length - 1
                     ? "Finish"
                     : "Next"}
                 </button>
               ) : (
                 <button
                   onClick$={nextQuestion}
                   disabled
                   class={styles["btn-disabled"]}
                 >
                   {" "}
                   {store.activeQuestion === question.length - 1
                     ? "Finish"
                     : "Next"}
                 </button>
               )}
             </div>
           ) : (
             <div class={styles["quiz-container"]}>
               <h3>Results</h3>
               <h3>Overall {(store.result.score / 25) * 100}%</h3>
               <p>
                 Total Questions: <span>{questions.length}</span>
               </p>
               <p>
                 Total Score: <span>{store.result.score}</span>
               </p>
               <p>
                 Correct Answers: <span>{store.result.correctAnswers}</span>
               </p>
               <p>
                 Wrong Answers: <span>{store.result.wrongAnswers}</span>
               </p>
               <button onClick$={() => window.location.reload()}>
                 Restart
               </button>
             </div>
           )}
         </div>
       </div>
     ) : (
       <div>
         <h1>Login</h1>
         <label>
           Username: <input bind:value={username} />
         </label>
         <label>
           Password: <input type="password" bind:value={password} />
         </label>
         <button onClick$={login}>Login</button>
       </div>
     )}
   </>
 );
});


export const head: DocumentHead = {
 title: "Quiz app home",
};

This code block renders the quiz questions, quiz results and its options. It conditionally renders the quiz results when `store.showResult` is `true`. Displays the overall score, the total number of questions, the total score, the number of correct answers, the number of wrong answers, and a button to restart the quiz.

Create a new file quiz.module.css inside the quiz directory and import into the index.tsx file.

import styles from "./quiz.module.css";

Now you can paste the styles from the GitHub repository to this file.

Implementing quiz functionality

Create dummy data

The data represents a set of quiz questions and answers, structured in TypeScript interfaces. The data is essential for populating the quiz content and testing the functionality of the application.

In the src directory, create a data.ts file and paste the code into the data.ts file.

interface Question {
  id: number;
  question: string;
  answers: string[];
  correctAnswer: string;
}
interface Quiz {
  totalQuestions: number;
  questions: Question[];
}

export const quiz: Quiz = {
  totalQuestions: 10,
  questions: [
    {
      id: 1,
      question: "What is the capital of Japan?",
      answers: ["Beijing", "Seoul", "Tokyo", "Bangkok"],
      correctAnswer: "Tokyo",
    },
    {
      id: 2,
      question: "Which gas do plants absorb from the atmosphere?",
      answers: ["Oxygen", "Carbon Dioxide", "Nitrogen", "Hydrogen"],
      correctAnswer: "Carbon Dioxide",
    },
  ],
};

Managing application state

In Qwik, there are two types of state, static or reactive. The static state encompasses any data that is serializable, encompassing a wide range of types, including strings, numbers, objects, and arrays. The reactive state, on the other hand, is created using useSignal() or useStore().

In this application, the useStore() function is employed, and it differs from useSignal() in its initialization process. Specifically, useSignal() accepts an initial value, which can be a simple value like 0, while useStore() expects an object as its initial value. To get started:

Import quiz object from the data file. Import the useStore() function from the @builder.io/qwik.

import { component$, useStore, $ } from "@builder.io/qwik";
import { quiz } from "~/data";

Define a QuizStore interface, this defines the structure of the state that the useStore function will manage.

interface QuizStore {
  activeQuestion: number;
  selectedAnswer: any;
  checked: boolean;
  selectedAnswerIndex: null;
  showResult: boolean;
  result: {
    score: number;
    correctAnswers: number;
    wrongAnswers: number;
  };
  nextQuestion: boolean;
}

Inside the component function, useStore is used to initialize the application’s state using the QuizStore interface. The useStore function accepts an initial state object as its argument, which populates the initial values of the state properties.

export default component$(() => {
  const store = useStore<QuizStore>({
    activeQuestion: 0,
    selectedAnswer: "",
    checked: false,
    selectedAnswerIndex: null,
    showResult: false,
    result: {
      score: 0,
      correctAnswers: 0,
      wrongAnswers: 0,
    },
    nextQuestion: false,
  });

  return <>// JSX</>;
});

export const head: DocumentHead = {
  title: "Quiz app home",
};

The store object is initialized with properties defined in the QuizStore interface, providing initial values for the quiz-related variables. These variables include the active question, selected answer, the selected answer's index, whether to show quiz results and an object to store the quiz result details such as score, correct answers, and wrong answers.

Handling user responses

To make the Quiz application useful, it’s important to be able to handle user responses and check if the user has entered the correct answers.

Accessing quiz data:

const {questions} = quiz

Here, the questions array is extracted from a quiz object. The array contains a list of quiz questions, each with its question statement, answer options, and the correct answer.

Extracting current question data:

const {question, answers, correctAnswer} = questions[store.activeQuestion];

store.activeQuestion represents the index of the currently active question.
Handling user selection:

const onAnswerSelected = $((anwser: any, idx: any) => {
  store.checked = true;
  store.selectedAnswerIndex = idx;
  if (anwser === correctAnswer) {
    store.selectedAnswer = true;
    console.log("true");
  } else {
    store.selectedAnswer = false;
    console.log("false");
  }
});

This function is called when a user selects an answer option. It takes two parameters: answer and idx (the index of the selected answer in the answers array). The selected answer is then compared with the correctAnswer for the current question.

In the above code implementation, you may have noticed the $ sign in front of the function declaration. Qwik divides the application into smaller units known as symbols. These symbols are finer-grained than components, allowing a component to be decomposed into multiple symbols. The process of splitting is managed by the Qwik optimizer.

The presence of the $ suffix serves as a signal to both the optimizer and developers, indicating when the division takes place. When using Qwik, developers need to be aware that certain specific rules come into play whenever they encounter the $ symbol. It's important to note that not all JavaScript code that is valid is a valid Qwik Optimizer transformation. At the end of the tutorial, there is a further discussion of the Qwik Optimizer transformation.

Navigating to the next question

This section demonstrates how to update the quiz state and move to the next question after a user selects an answer.

Define nextQuestion function:

const nextQuestion = $(() => {
  // update the quiz score
});

The nextQuestion function is responsible for advancing to the next quiz question after the user has interacted with the current one.

Updating the quiz score and answer status:

// update the quiz score
if (store.selectedAnswer) {
   store.result.score += 5;
   store.result.correctAnswers += 1;
 } else {
   store.result.wrongAnswers += 1;
 }

If the user's selected answer store.selectedAnswer is correct, increment the quiz score by 5 points and update the count of correct answers in the result object. If the selected answer is incorrect, increment the count of wrong answers in the result object.

Navigating to the next question:

if (store.activeQuestion !== questions.length - 1) {
  store.activeQuestion += 1;
} else {
  store.activeQuestion = 0;
  store.showResult = true;
}

This checks if the current question is not the last question in the quiz by comparing store.activeQuestion to questions.length - 1. If it's not the last question, we increment store.activeQuestion to move to the next question.

Resetting state for the next question:

store.selectedAnswer=null;
store.checked=false;

This resets the store.selectedAnswer to null to clear the user's selected answer for the next question. Then, reset store.checked to false, indicating that the user has not yet interacted with the next question.

Adding a countdown timer

It’s important to incorporate a countdown timer into the quiz application. A timer is a valuable addition to the application as it provides users with a time limit for answering each question.

Update application state:

Add the following code In the QuizStore interface and QuizStore state instance:

interface QuizStore {
  timer: number;
  intervalId: any;
}

export default component$(() => {
  const store = useStore<QuizStore>({
    // rest of code
    timer: 10,
    intervalId: null
  })
})

The intervalId keeps track of the active timer, the timer variable tracks the number of available seconds.

Create startTimer function:

The startTimer function starts a countdown timer if there isn’t an already active timer. The timer checks the remaining time and decrements by 1 second. Once time runs out, it proceeds to the next question.

const startTimer = $(() => {
  if (!store.intervalId) {
    console.log("timer started");
    const intervalId = setInterval(() => {
      if (store.timer > 0 && !store.showResult) {
        store.timer -= 1;
      } else if (store.timer === 0) {
        nextQuestion();
      }
    }, 1000);
    store.intervalId = intervalId;
  }
});

Update nextQuestion function:

There is a need to trigger the startTimer function and reset the state in store.timer after navigating to the next question.

const nextQuestion = $(() => {
  // rest of code
  store.checked = false;
  store.timer = 10;
  store.nextQuestion = true;
});

Ensuring timer resumability:

When creating the timer functionality, it is important to have it run on the browser after rendering. In that case, the useVisibleTask$() function from Qwik is used. The useVisibleTask$() function registers a hook that triggers when the component becomes visible within the viewport. The hook executes at least once when the component loads in the web browser. It provides almost the same functionality as useEffect in React.

In the dependency section, import useVisibleTask$:

import { component$, useStore, $, useVisibleTask$} from "@builder.io/qwik";

Inside the component body, define the useVisibleTask$:

export default component$(() => {
  // rest of code

  if (store.nextQuestion === true) {
    startTimer();
  }

  useVisibleTask$(() => {
    startTimer();
  });
});

This hook triggers the startTimer function when the component turns visible.

Creating authentication in Qwik

Only authenticated users should be able to access the quiz. Let's see how to create authentication in Qwik and the handling of server logic. The server$() function is a tool in Qwik that enables the creation of functions that run on the server. This capability is useful for tasks involving database access or server-side operations.

server$() is a way to establish a Remote Procedure Call (RPC) mechanism between the client and server. When you create a function using server$(), it takes on a specific signature: ([AbortSignal, ] ...): Promise<T>. Let’s explore how to integrate functions created with server$() into our application.

Create server authentication function:

In the quiz directory, create a file called server.ts and paste the below code.

import { server$ } from "@builder.io/qwik-city";

export const serverAuthenticate = server$(
  (username: string, password: string) => {
    // Perform authentication logic here (e.g., check credentials against a database)
    if (username === "user" && password === "password") {
      // Authentication successful, return user information
      return { id: 1, username: "user" };
    } else {
      // Authentication failed, return null
      return null;
    }
  },
);

This code is meant to run exclusively on the server.

Import serverAuthenticate:

In the index.tsx file in the quiz directory, import the serverAuthenticate function from the server file.

import { serverAuthenticate } from "./server";

Update Application State:

Add the following code In the QuizStore interface and QuizStore state instance:

interface QuizStore {
  // rest of code
  user: any;
}

export default component$(() => {
  const store = useStore<QuizStore>({
    // rest of code
    user: null,
  });
});

Importing useSignal() into application dependencies:

useSignal() creates a form of state called a reactive signal. It takes an initial value and returns a reactive signal. The reactive signal returned consists of an object with a single property called .value.

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

Defining username and password state:

In this application, useSignal is used to create state variables for tracking the value of the username and password entered by the user. Inside the component body, add the following code.

export default component$(() => {
  const username = useSignal("");
  const password = useSignal("");
});

Create login functionality:

When a user attempts to log in, the login function is triggered. Inside the login function, a server authenticated process is initiated by calling the serverAuthenticate function. This function communicates with the server to verify the provided credentials.

const login = $(async () => {
  try {
    const authenticatedUser = await serverAuthenticate(
      username.value,
      password.value,
    );
    console.log("authenticatedUser", authenticatedUser);
    if (authenticatedUser) {
      store.user = authenticatedUser;
    } else {
      alert("Authentication failed. Please check your credentials.");
    }
  } catch (error) {
    console.error("Authentication error:", error);
    // Handle the error (e.g., show an error message)
    alert("An error occurred during authentication.");
  }
});

After creating and testing the login functionality, an issue has arisen. useVisibleTasks$() triggers when the component mounts, which means our timer starts running before log in. To prevent this, let's update the application.

Switch from using useVisibleTask$ to useTask$

Introduction to useTask$:

useTask$() is similar to useVisibleTask$() but provides added functionality. Unlike useVisibleTask$() which runs immediately after the component renders, useTask$() is reactive. This means that a task is trackable and re-executes when the tracked state changes.

In the dependencies, import useTask$ from @builder.io/qwik and import isServer from @builder.io/qwik/build.

import { component$, useStore, $, useSignal, useTask$} from "@builder.io/qwik";
import { isServer } from '@builder.io/qwik/build';

Create userAuthenticated state:

A variable for tracking authenticated users needs to be set up. The useTask$ tracks this variable and runs the timer when the user is successfully logged in.

Using useSignal, create a reactive signal for checking user authentication.

const userAuthenticated = useSignal(false);

Update login function to update userAuthenticated value:

const login = $(async () => {
  try {
    // rest of code
    if (authenticatedUser) {
      store.user = authenticatedUser;
      userAuthenticated.value = true;
    } else {
      // rest of code
    }
  } catch (error) {
    // rest of code
  }
});

The userAuthenticated.value updates to true once the user is authenticated.

Switch from using useVisible$() to useTask$:

Comment out the useVisbleTask$ and replace it with the code block below.

// Start the timer using useVisibleTask$ only if the user is authenticated
// useVisibleTask$(() => {
// // userAuthenticated.value = true;
// startTimer();
// })

useTask$(({ track }) => {
  track(() => userAuthenticated.value);
  if (isServer) {
    return;
  }
  userAuthenticated.value = true;
  delay(1000).then(() => startTimer());
});

With this update, the startTimer function runs once the user is authenticated.

Voila. If you made it this far, the quiz application is complete and it’s time for you to play with it. Navigate to http://localhost:5173/quiz/ in your browser to see the application.

Deploying the quiz application

Qwik makes it easy to deploy applications with its ready-to-use integrations. Qwik City provides middleware that acts as glue code for connecting to server rendering frameworks. It enables connection to Cloudflare, Netflify, Vercel, and Express. To activate the middleware for connecting with a server rendering framework, run the below command.

npm run qwik add

This provides a wide array of middleware adapters to choose from based on your preference.

This tutorial makes use of Netlify edge functions, so choose Netlify Edge from the provided options.

With edge functions our application is thus rendered at an edge location near our users. Before proceeding further, install the Netlify CLI.

To install the Netlify CLI on Mac, in your terminal window, run the following command.

sudo npm i -g netlify-cli

Production build

To build the application for production, use the build command. This command runs npm run build.server and npm run build.client. This builds our site for both ssr and static content.

Run the following command:

npm run build

Deployment

To deploy the application for development, run the following command:

npm run deploy

This command creates a website draft URL. The draft URL provides the ability to see how the application runs when served before deploying to production. Run the website draft URL to see how it behaves when served. If there are no errors, run the production deployment.

To deploy to production, run the following command:

netlify deploy --build --prod

Thank you for reading this far. You can access the production version of the application at the following URL: https://qwikquizapp.netlify.app/quiz/.

To log in, use the following credentials:

  • Username - user
  • Password - password.

Also, you can find the code on the Bejamas page on GitHub.

Conclusion

Qwik is a JavaScript framework that excels in delivering fast and efficient web applications. Through this tutorial, we've built a functional Quiz application that showcases Qwik's capabilities. This application can be extended to handle various scenarios, including computer-based tests, multiple-choice questions for educational institutions, and board certification exams.

This tutorial has provided valuable insights into Qwik and how it can be used to create high-performance web applications. Thank you for joining this Qwik journey, and I look forward to seeing the amazing applications you'll build with this framework.

Recap of the key features and benefits of using Qwik

  • Efficiency and speed: Qwik is great at building efficient and resumable web applications. It excels at delivering fast application startup times on the client side. By downloading and executing only the necessary code for user actions, Qwik excels in delivering fast web applications. It eliminates unnecessary code bloat and ensures a responsive and performant user experience.
  • No hydration required: unlike traditional server-side rendered (SSR) frameworks, Qwik operates without hydration. It cuts out a resource-intensive process of restoring event listeners, and rehydrating the component tree. Qwik's approach hence streamlines the client-side boot-up process.
  • Component-based architecture: components serve as the building blocks of Qwik applications. Qwik enforces a structured component-based architecture, making it easy to create modular code.
  • State management: Qwik provides different types of state management, including static and reactive state. In the tutorial, useStore() is used for managing the application state. It initializes the state with an object, allowing the management and updating the state of the application.
  • Server-side logic: with server$(), Qwik allows the creation of server-side logic that is invokable from the client side.
  • Production-ready: Qwik includes production-ready features, such as server rendering, static site generation, and routing, making it suitable for building both small and large-scale web applications.

Further exploration of advanced usage of Qwik

Let's explore critical aspects that underpin Qwik's efficiency and power, such as serialization and the Qwik Optimizer. Serialization, in the context of Qwik, plays a pivotal role in enabling resumable web applications.

What is the purpose of serialization?

Serialization, at its core, involves converting data into a format to be stored or transmitted and later reconstructed. This process is often compared to JSON.stringify, a common method for converting JavaScript objects into strings. However, JSON has its limitations, and this is where Qwik steps in to provide solutions.

Overcoming JSON limitations in Qwik

JSON's limitations pose challenges for developers, especially when dealing with complex application states. Qwik addresses some of these limitations head-on by:

  1. Handling circular references: JSON produces a Directed Acyclic Graph (DAG), which means it cannot handle circular references. This limitation becomes significant when dealing with application states that involve circular references. Qwik ensures that circular references within the graph of objects are saved during serialization and later restored, overcoming this limitation.
  2. Serializing complex object types: JSON struggles with certain object types, such as DOM references and dates, which it cannot serialize. Qwik's serialization format comes to the rescue by ensuring that these problematic objects can be serialized and restored. This extended support also includes types like URL objects, promises, and even map and set instances.

While Qwik goes a long way in addressing the limitations of JSON serialization, it's essential to note that there are some limitations it cannot solve:

  1. Serialization of classes: JSON's inability to serialize classes, particularly instances of classes, remains a limitation. Qwik addresses this by supporting some built-in classes like Date, URL, Map, and Set. However, for user-defined classes, serialization remains a challenge.
  2. Serialization of streams: the complex nature of streams makes them challenging to serialize. In cases where serialization is not feasible, the code should run only on the client, acknowledging this limitation.

Writing Qwik applications with serialization in mind

To harness the full power of Qwik and its resumability capabilities, developers need to adopt a new mindset when writing web applications. It's imperative to consider the resumability of both the framework and the application itself. This means expressing components and entities in a way that can be serialized and resumed without the need for re-bootstrapping.

Developers must shift from a heap-centric approach to a DOM-centric one. This paradigm shift requires a change in behavior and a retooling of web development skills. Frameworks like Qwik play a crucial role in providing guidance and APIs to ease this transition, making it easier for developers to write applications with serialization and resumability in mind.

Additional benefits of resumability

While server-side rendering is the most apparent benefit of resumability, there are other advantages to consider:

  1. Progressive Web Apps (PWAs): serializing existing PWAs ensures that users don't lose context when they return to the application, enhancing the user experience.
  2. Improved rendering performance: resumability allows for fine-grained lazy-loading, meaning only changed components need to be re-rendered. This results in improved rendering performance and a more responsive application.
  3. Reduced memory pressure: especially crucial on mobile devices, resumability can decrease memory pressure by optimizing the usage of system resources.
  4. Progressive interactivity: even static websites can benefit from resumability by enhancing interactivity as users engage with the site.

Understanding the Qwik Optimizer

The Qwik Optimizer operates as a Vite plugin during bundling. Its primary purpose is to divide applications into small, lazy-loadable chunks. It identifies and moves expressions, typically functions, into new files, leaving behind references to the original locations. The dollar sign ($) informs the optimizer which functions to extract into separate files and which ones to leave untouched. The optimizer relies on the $ suffix to identify functions for transformation.

Rules for using $

The Qwik optimizer uses $ as a signal to extract code, and developers must adhere to specific rules when working with it. These rules ensure that the extracted code remains valid and optimized.

1. Allowed Expressions

The first argument of any function ending with $ must meet specific restrictions:

  • It should contain literals without local identifiers.
  • Importable identifiers are allowed.

2. Closures

  • Closures have slightly relaxed rules, allowing local identifiers to be referenced and captured.
  • Captured variables must be declared as a const.
  • Captured variables must be serializable.

3. Module-declared variables

  • Functions extracted by the Optimizer should refer to top-level symbols, either imported or exported.

Consider a scenario where you want to implement a scroll event handler lazily. The traditional approach requires the manual separation of code into different files and hardcoding chunk names. However, Qwik simplifies this process by using the $() marker function.

By wrapping a function in $(), developers signal the Optimizer to move the function to a new file for lazy loading. It allows for easier code organization and chunking without manual intervention.

Symbol extraction

The Qwik Optimizer excels at breaking down code into smaller chunks, enhancing the application's maintainability and performance. For instance, when a component is defined with component$(), the Optimizer extracts its methods into separate files, improving code separation and facilitating lazy loading.

Capturing the lexical scope

In complex scenarios where extracted function closures capture variables, Qwik handles the situation gracefully. It stores these variables in the QRL (Qwik Resource Locator) and generates code that restores the lexical scope when needed. This powerful feature enables the extraction of more functions into lazy-loaded resources, contributing to more efficient code splitting.

Final thoughts

Understanding serialization and the $ in Qwik is essential for harnessing the full potential of the framework. By addressing JSON's limitations and emphasizing resumability, Qwik empowers developers to build high-performance web applications that are not only efficient but also capable of delivering a superior user experience.

The dollar sign ($) in Qwik is not just a symbol. It's a gateway to optimizing your web applications. By understanding the rules and techniques associated with the dollar sign, developers can leverage Qwik's capabilities to create highly modular, efficient, and maintainable web applications.