Building English Quiz App - My First SolidJS App as a React Developer

lico

SeongKuk Han

Posted on June 11, 2023

Building English Quiz App - My First SolidJS App as a React Developer

My First SolidJS App: English Quiz App as a React Developer

I have primarily focused on React projects for the past three years. When I initially made the decision to learn a frontend framework, the top three frameworks were React, Vue and Angular. Out of these options, I opted for React. Since then, I haven't tried learning other frameworks.

About three months ago, I arrived in Frankfurt. I am currently studying English by myself to pursue job opportunities here. Prior to diving into employment, I realized that this might be a good opportune moment to try a new frontend framework.


Why SolidJS

SolidJS: Simple and performant reactivity for building user interfaces.

If you have ever prepared for a frontend interview, especially, for a React Developer, you may have come across the question. 'What is the VirtualDOM?'.

In React, the VirtualDOM resides in the memory and serves as a representation of the RealDOM. By comparing the VirtualDOM with the RealDOM, React identifies the specific portions of the RealDOM that requires updating and it comes improving performance.

However, I was amazed to discover that both Svelte and SolidJS which are getting popular these days boast even better performance than React, and they accomplish this without the need for the VirtualDOM.

Intrigued by this information, I decided to give one of them a try. After some research, I noticed that SolidJS bears a striking resemblance to React. This led me to believe that SolidJS might have a lower learning curve for me compared to Svelte.


Initially, I had considered building a Todo app, which is a common choice for many people as their first frontend toy project. However, I found the idea to be somewhat dull, and I was certain that my interest in the app would be gone when I finish it. Instead, I decided to create an English quiz app. This alternative seemed more enjoyable, and since I would personally use it for studying purposes, it would be more useful to me as well.

In this article, I will walk you through the code of the app page by page. The explanation may not be entirely smooth, as I won't be following a step-by-step approach. I aim to cover everything I have learned about SolidJS and occasionally draw comparisons with React. If you come across any errors or have suggestions, please feel free to comment down below or submit pull requests on this Github repository.

Lastly, please, forget about the design. I need to improve my skills in it.


Intro Page

Quiz Page

Result Page

Intro Page

https://solid-english-quiz.vercel.app as a vercel domain

Github Repository

In the Github repository associated with this article, there is a separate branch called 'devto'. This branch is specifically created for the purpose of this post. As I continue to maintain the repository, the 'main' branch might diverge from the code presented in this article.


Set Up The Project

1. Create a SolidJS project

> npx degit solidjs/templates/ts english-quiz
Enter fullscreen mode Exit fullscreen mode

2. Install packages with your prefer package manager

> pnpm install
Enter fullscreen mode Exit fullscreen mode

I added two more packages.

  • SolidJS Router: A universal router for SolidJS
  • Tailwind: A utility-first CSS framework packed with classes like flex, pt-4, text-center and rotate-90 that can be composed to build any design, directly in your markup.

3. Install SolidJS Router

> pnpm install @solidjs/router
Enter fullscreen mode Exit fullscreen mode

4. Install Tailwind

> pnpm install -D tailwindcss postcss autoprefixer
Enter fullscreen mode Exit fullscreen mode

5. Create a config file for tailwind

> npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

6. Change tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./src/**/*.{js,jsx,ts,tsx}"],
  theme: {
    extend: {
      height: {
        screenUI: "100svh", // For mobile browsers
      },
    },
    // Extend Font
    fontFamily: {
      caveat: ["Caveat"],
      roboto: ["Roboto"],
    },
  },
  plugins: [],
};
Enter fullscreen mode Exit fullscreen mode

I have added two google fonts to the project and a custom height style in tailwind, which reflects the smallest possible viewport height.

7. Change index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
    />
    <meta name="theme-color" content="#000000" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Caveat:wght@400;700&display=swap"
      rel="stylesheet"
    />
    <link
      href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap"
      rel="stylesheet"
    />
    <link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
    <title>English Quiz</title>
    <meta
      name="description"
      content="Test your English and practice English by solving quiz"
    />
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>

    <script src="/src/index.tsx" type="module"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

I have added link tags to apply the Google fonts and included some metadata such as description. Additionally, I have modified the content attribute of the viewport meta tag to prevent scaling, as this can lead to an uncomfortable user experience on mobile devices.

8. Change index.css

@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
  font-size: 110%;
}

body {
  margin: 0;
  touch-action: pan-x pan-y;
}
Enter fullscreen mode Exit fullscreen mode

In order apply the Tailwind Css styles, I have included the lines @tailwind ~ in the file. font-size property is used to specify the desired gap between the two fonts, and the touch-action property is utilized to prevent scaling on certain mobile browsers, such as Safari.


Router & App

1. Create src/MainRouter.tsx

import { Route, Router, Routes } from "@solidjs/router";
import Intro from "./pages/Intro";
import Quiz from "./pages/Quiz";
import NotFound from "./pages/NotFound";
import Result from "./pages/Result";

const MainRouter = () => {
  return (
    <Router>
      <Routes>
        <Route path="/" component={Intro} />
        <Route path="/quiz" component={Quiz} />
        <Route path="/result" component={Result} />
        <Route path="*" component={NotFound} />
      </Routes>
    </Router>
  );
};

export default MainRouter;
Enter fullscreen mode Exit fullscreen mode

Before finalizing the entire code of the project, please be aware that there may be some errors present. If you plan to run the development server at any point, I recommend commenting out the lines that are causing errors. This will allow you to proceed with running the server without encountering any issues.

2. Create src/components/templates/index.tsx

import { Component, JSXElement } from "solid-js";
import fontSignal from "../../../stores/fontSignal";

const BaseTemplate: Component<{
  children?: JSXElement;
}> = (props) => {
  const [font] = fontSignal;

  return (
    <div class={`w-screen h-screenUI bg-lime-50 ${font()}`}>
      {props.children}
    </div>
  );
};

export default BaseTemplate;
Enter fullscreen mode Exit fullscreen mode

The BaseTemplate component serves as a way to provide a default style to its child components. It applies a specific font and utilizes the h-screenUI class, which is a custom height class defined in the Tailwind CSS configuration file. By using this component, we can ensure consistency in the style across all pages of our application.

3. Create src/stores/fontSignal.tsx

import { Accessor, Setter, createSignal, createEffect } from "solid-js";

type Font = "font-caveat" | "font-roboto";

const defaultFont: Font = "font-caveat";
const STORAGE_FONT = "STORAGE_FONT";

const fontSignal = ((): [Accessor<Font>, Setter<Font>, VoidFunction] => {
  const initFont: Font =
    (localStorage.getItem(STORAGE_FONT) as Font) || defaultFont;
  const [font, setFont] = createSignal<Font>(initFont);

  const nextFont = () => {
    let newFont: Font = "font-caveat";

    switch (font()) {
      case "font-caveat":
        newFont = "font-roboto";
        break;
    }

    localStorage.setItem(STORAGE_FONT, newFont);
    setFont(newFont);
  };

  createEffect(() => {
    const rootElement = document.querySelector<HTMLElement>(":root");

    switch (font()) {
      case "font-caveat":
        rootElement?.style.setProperty("font-size", "110%");
        break;
      case "font-roboto":
        rootElement?.style.setProperty("font-size", "100%");
        break;
    }
  });

  return [font, setFont, nextFont];
})();

export default fontSignal;
Enter fullscreen mode Exit fullscreen mode

I struggled to decide on a suitable name for the directory, and eventually settled on 'stores'. However, if you have alternative suggestions, feel free to use them.

The createSignal function in SolidJS is comparable to the useState hook in React. However, unlike useState, createSignal can be used anywhere, not just within components.

Similarly, createEffect in SolidJS is similar to useEffect in React. In the code, you may have noticed that there are no dependencies specified. When the font() function is called, the getter function remembers where it was called from, then when you change the font using the font setter, the createEffect will be triggered.

The font-caveat class represents a slightly smaller font size compared to font-roboto, that is why I gave a difference in font size between the two.

The nextFont function is used to switch between fonts.

To ensure that the latest font selected by users is preserved, the data is stored in the local storage. This allows the application to apply the font even after a page reload.


[+Edit] Since create* doesn't matter where it's defined at any scope, you can write the code like below.

import { createSignal, createEffect } from "solid-js";

type Font = "font-caveat" | "font-roboto";

const defaultFont: Font = "font-caveat";
const STORAGE_FONT = "STORAGE_FONT";

const initFont: Font =
  (localStorage.getItem(STORAGE_FONT) as Font) || defaultFont;
const [font, setFont] = createSignal<Font>(initFont);

const nextFont = () => {
  let newFont: Font = "font-caveat";

  switch (font()) {
    case "font-caveat":
      newFont = "font-roboto";
      break;
  }

  localStorage.setItem(STORAGE_FONT, newFont);
  setFont(newFont);
};

createEffect(() => {
  const rootElement = document.querySelector<HTMLElement>(":root");

  switch (font()) {
    case "font-caveat":
      rootElement?.style.setProperty("font-size", "110%");
      break;
    case "font-roboto":
      rootElement?.style.setProperty("font-size", "100%");
      break;
  }
});

export { font, setFont, nextFont };

export default {
  font,
  setFont,
  nextFont,
};
Enter fullscreen mode Exit fullscreen mode

4. Create src/pages/NotFound.tsx

import BaseTemplate from "../../components/templates/BaseTemplate";

const NotFound = () => {
  return (
    <BaseTemplate>
      <div
        class="mx-auto flex flex-col w-fit text-center gap-y-4"
        style={{ "padding-top": "20%" }}
      >
        <h1 class="text-8xl">404</h1>
        <span class="text-3xl">Page Not Found</span>
        <a href="/" class="text-lg text-gray-800">
          GO HOME
        </a>
      </div>
    </BaseTemplate>
  );
};

export default NotFound;
Enter fullscreen mode Exit fullscreen mode

This page will be displayed when users attempt to access a page that does not exist.

5. Change src/App.tsx

import type { Component } from "solid-js";
import MainRouter from "./MainRouter";
import PreventDoubleTab from "./components/functions/PreventToDoubleTab";

const App: Component = () => {
  return (
    <>
      <PreventDoubleTab />
      <MainRouter />
    </>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

6. Create src/components/functions/PreventDoubleTab

import { onCleanup, onMount } from "solid-js";

const PreventDoubleTab = () => {
  onMount(() => {
    const preventDefault = (e: MouseEvent) => {
      e.preventDefault();
    };
    document.addEventListener("dblclick", preventDefault);

    onCleanup(() => {
      document.removeEventListener("dblclick", preventDefault);
    });
  });

  return null;
};

export default PreventDoubleTab;
Enter fullscreen mode Exit fullscreen mode

I encountered an issue where double-tapping on my iPhone SE2 caused the page to zoom in, despite setting user-scalable=no in the viewport meta tag. To address this problem, I implemented this component that prevents zooming in with a double-tap gesture.

In this component, I utilized the onMount function. Even if you don't use onMount, the code will still execute the code once, however it guarantees that the code will not run during server-side rendering (SSR). You can find more information about onMount in the SolidJS tutorial here.

Addtionally, I utilized the onCleanup function, which is used to release resources. You can call it at any scope.


Intro Page

1. Create src/types/quiz.ts

export interface Quiz {
  quizName: string;
  quizList: {
    answer: string;
    question: string;
  }[];
}

export interface PlayHistory {
  [key: string]: number;
}
Enter fullscreen mode Exit fullscreen mode

2. Create src/data/quizzes.ts

import { Quiz } from "../types/quiz";

const a1Words: Quiz = {
  quizName: "A1 English Words",
  quizList: [
    {
      answer: "hello",
      question: "A common greeting used to acknowledge or greet someone.",
    },
    {
      answer: "goodbye",
      question:
        "A parting phrase used to bid farewell or take leave of someone.",
    },
    {
      answer: "yes",
      question: "An affirmative response or agreement.",
    },
    {
      answer: "no",
      question: "A negative response or denial.",
    },
    {
      answer: "thank you",
      question:
        "An expression of gratitude or appreciation for something received or done.",
    },
    {
      answer: "please",
      question:
        "A polite word used to make a request or express a desire for something.",
    },
    {
      answer: "sorry",
      question:
        "An expression used to apologize or show regret for a mistake or wrongdoing.",
    },
    {
      answer: "excuse me",
      question:
        "A polite phrase used to get someone's attention or ask for forgiveness.",
    },
    {
      answer: "how are you",
      question:
        "A common greeting used to ask about a person's well-being or current state.",
    },
    {
      answer: "my name is",
      question: "An introduction phrase used to state one's name.",
    },
  ],
};

const quizzes: Quiz[] = [
  a1Words,
];

export default quizzes;
Enter fullscreen mode Exit fullscreen mode

I have generated the data using ChatGPT. You can utilize ChatGPT to generate more data.

3. Create src/pages/Intro/index.tsx

import { For, createSignal } from "solid-js";
import BaseTemplate from "../../components/templates/BaseTemplate";
import quizzesData from "../../data/quizzes";
import { useNavigate } from "@solidjs/router";
import KeyEvent, { Key } from "../../components/functions/KeyEvent";
import Button from "../../components/Button";
import fontSignal from "../../stores/fontSignal";
import { evalScore, getPlayHistory } from "../../lib/playHistory";
import Badge, { BadgeColor } from "../../components/Badge";

const Intro = () => {
  const [, , nextFont] = fontSignal;
  const [selectedQuizIdx, setSelectedQuizIdx] = createSignal(0);
  const navigate = useNavigate();
  const quizzes = () => {
    const quizzesListToShow = [];
    const startIdx = selectedQuizIdx();

    for (let i = startIdx - 1; i < startIdx + 2; i++) {
      if (i >= 0 && i < quizzesData.length) {
        quizzesListToShow.push(quizzesData[i]);
      } else {
        quizzesListToShow.push(null);
      }
    }

    return quizzesListToShow;
  };

  const nextArrowVisible = () => {
    return selectedQuizIdx() === 0 && quizzes().length >= 3;
  };

  const handleKeyEvent = (key: Key) => {
    switch (key) {
      case "Enter":
        const selectedQuiz = quizzes()[1];
        if (!selectedQuiz) break;
        navigateToQuiz(selectedQuiz.quizName);
        break;
      case "ArrowUp":
        setSelectedQuizIdx((prevSelctedQuizIdx) =>
          prevSelctedQuizIdx > 0 ? prevSelctedQuizIdx - 1 : prevSelctedQuizIdx
        );
        break;
      case "ArrowDown":
        setSelectedQuizIdx((prevSelctedQuizIdx) =>
          prevSelctedQuizIdx < quizzesData.length - 1
            ? prevSelctedQuizIdx + 1
            : prevSelctedQuizIdx
        );
        break;
    }
  };

  const navigateToQuiz = (quizName: string) => {
    navigate("/quiz", {
      state: {
        quizName,
      },
    });
  };

  return (
    <BaseTemplate>
      <KeyEvent
        onKeyUp={handleKeyEvent}
        keys={["Enter", "ArrowUp", "ArrowDown"]}
      />
      <div class="text-center h-full">
        <h1 class="text-5xl h-2/6 block flex items-end justify-center p-4">
          English Quiz
        </h1>
        <div class="mt-16 text-2xl flex flex-col gap-y-6 px-8">
          <For each={quizzes()} fallback={<>Loading...</>}>
            {(quiz, quizIdx) => {
              const handleQuizClick = () => {
                if (quizIdx() === 1) {
                  navigateToQuiz(quiz?.quizName || "");
                } else {
                  setSelectedQuizIdx((prevIdx) => prevIdx + quizIdx() - 1);
                }
              };

              let badge: { color: BadgeColor; text: string } | undefined =
                undefined;
              const score = quiz ? getPlayHistory(quiz.quizName) : undefined;

              if (score) {
                const scoreGrade = evalScore(score);

                if (scoreGrade === 1) {
                  badge = {
                    color: "blue",
                    text: "1",
                  };
                } else if (scoreGrade === 2) {
                  badge = {
                    color: "green",
                    text: "2",
                  };
                } else {
                  badge = {
                    color: "red",
                    text: "3",
                  };
                }
              }

              return (
                <div class="relative mx-auto">
                  {badge && (
                    <div class="absolute right-full bottom-full translate-y-2/3">
                      <Badge size="sm" color={badge.color}>
                        {badge.text}
                      </Badge>
                    </div>
                  )}
                  <div
                    onClick={quiz === null ? undefined : handleQuizClick}
                    class="hover:cursor-pointer relative"
                    classList={{ "text-3xl animate-bounce": quizIdx() === 1 }}
                  >
                    {quiz?.quizName}
                  </div>
                </div>
              );
            }}
          </For>
          {nextArrowVisible() && (
            <div onClick={() => setSelectedQuizIdx(1)}>⬇️</div>
          )}
        </div>
        <div class="fixed left-4 bottom-4">
          <Button color="lime" onClick={nextFont}>
            Font
          </Button>
        </div>
      </div>
    </BaseTemplate>
  );
};

export default Intro;
Enter fullscreen mode Exit fullscreen mode

If you have experience with React Router, you will likely have no trouble using SolidJS Router.

In the quiz page, when a user selects a specific item, they will be navigated to the quiz page, and the selected quiz name will be delivered through the location state.

There will be three quiz names displayed on the screen, depending on which quiz is selected. Each quiz name will have a small badgeindicating the latest score, which will range from 1 to 3.

Furthermore, PC users will be able to use arrow keys to select the desired quiz.

As mentioned before, the signal defined in fontSignal can be used at any scope.

In React, you may have frequently used map to render array elements. In SolidJS, you can use the For or Index component to render array elements.

...
classList={{ "text-3xl animate-bounce": quizIdx() === 1 }}
...
Enter fullscreen mode Exit fullscreen mode

If you tried the classNames package in React, you may find a similar concept. You can use conditional classes using classList attribute.

5. Create src/components/functions/KeyEvent/index.tsx

import { onCleanup, Component, onMount } from "solid-js";

export type Key = "Enter" | "ArrowUp" | "ArrowDown" | "1" | "2" | "3" | "4";

const KeyEvent: Component<{
  keys: Key | Key[];
  onKeyUp?: (key: Key) => void;
}> = (props) => {
  onMount(() => {
    const handleWindowKeyEvent = (e: KeyboardEvent) => {
      const k = e.key as Key;

      if (props.keys.includes(k)) {
        props.onKeyUp?.(k);
      }
    };

    window.addEventListener("keyup", handleWindowKeyEvent);

    onCleanup(() => {
      window.removeEventListener("keyup", handleWindowKeyEvent);
    });
  });

  return null;
};

export default KeyEvent;
Enter fullscreen mode Exit fullscreen mode

I have defined Key type only for keys I expected users will use.

5. Create src/components/Button/index.tsx

import { Component, JSX } from "solid-js";

interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
  color: "lime";
}

const Button: Component<ButtonProps> = (props) => {
  const newClass = () => {
    let className = "rounded-lg p-1 cursor-pointer";

    switch (props.color) {
      case "lime":
        className += " text-white bg-lime-500";
    }

    if (props.class) className += ` ${props.class}`;

    return className;
  };

  return <button {...props} class={newClass()} />;
};

export default Button;
Enter fullscreen mode Exit fullscreen mode

newClass will be called whenever there are changes to the props.color or props.class props.

6. Create src/components/Badge/index.tsx

import { Component, JSXElement, mergeProps } from "solid-js";

export type BadgeColor = "red" | "blue" | "green";

export type BadgeFontSize = "sm" | "md";

const Badge: Component<{
  color: BadgeColor;
  size?: BadgeFontSize;
  children?: JSXElement;
}> = (props) => {
  const mergedProps = mergeProps(
    {
      size: "sm" as BadgeFontSize,
    },
    props
  );

  const colorClasses = {
    red: "bg-red-500",
    blue: "bg-blue-500",
    green: "bg-green-500",
  };

  const sizeClasses = {
    sm: "px-2 py-1 text-sm",
    md: "px-3 py-2 text-base",
  };

  const badgeClasses = `${colorClasses[mergedProps.color]} ${
    sizeClasses[mergedProps.size]
  } text-white font-medium rounded-full inline-block w-fit`;

  return <span class={badgeClasses}>{props.children}</span>;
};

export default Badge;
Enter fullscreen mode Exit fullscreen mode

In SolidJS, it's important to avoid destructuring props as it can cause them to lose their reactivity. When props are destructured, the individual properties lose their reactive nature.

To provide Default values for props, you can utilize the mergeProps function. With mergeProps, you can combine multiple props objects and maintain their reactivity.

7. Create src/lib/playHistory.ts

import { PlayHistory } from "../types/quiz";

const STORAGE_PLAY_HISTORY = "STORAGE_PLAY_HISTORY";

export const getPlayHistory = (title: string): number => {
  const historyMap = JSON.parse(
    localStorage.getItem(STORAGE_PLAY_HISTORY) || "{}"
  ) as PlayHistory;

  let score = 0;
  try {
    score = historyMap[title] || 0;
  } catch (e) {
    console.error(e);
    localStorage.removeITem(STORAGE_PLAY_HISTORY);
  }

  return score;
};

export const savePlayHistory = (title: string, score: number) => {
  const historyMap = JSON.parse(
    localStorage.getItem(STORAGE_PLAY_HISTORY) || "{}"
  ) as PlayHistory;

  try {
    historyMap[title] = score;
    localStorage.setItem(STORAGE_PLAY_HISTORY, JSON.stringify(historyMap));
  } catch (e) {
    console.error(e);
    localStorage.setItem(
      STORAGE_PLAY_HISTORY,
      JSON.stringify({
        [title]: score,
      })
    );
  }
};

export const evalScore = (score: number): 1 | 2 | 3 => {
  if (score >= 1) return 1;
  else if (score >= 0.6) return 2;
  else return 3;
};
Enter fullscreen mode Exit fullscreen mode

The savePlayHistory function is responsible for storing the score in the local storage. If it fails to store the score for any reason, it recreates the score map to ensure data consistency.

The getplayHistory function retrieves the score from the local storage based on the quiz name. If there is no existing history for the quiz, it returns a score of 0.

The evalScore function calculates a grade between 1 and 3 based on the provided score, which ranges from 0 to 1. A score of 1 represents the best grade.


Quiz Page

1. Create src/pages/Quiz/index.tsx

import { useLocation, useNavigate } from "@solidjs/router";
import Question from "../../components/Question";
import quizzesData from "../../data/quizzes";
import TopProgressBar from "../../components/TopProgressBar";
import BaseTemplate from "../../components/templates/BaseTemplate";
import {
  Show,
  createEffect,
  createMemo,
  createSignal,
  onMount,
  untrack,
} from "solid-js";
import { shuffleArray } from "../../lib/utils";
import { Quiz as TQuiz } from "../../types/quiz";

const Quiz = () => {
  const navigate = useNavigate();
  const location = useLocation<{
    quizName?: string;
  }>();
  const [quiz, setQuiz] = createSignal<TQuiz | undefined>();
  const [choices, setChoices] = createSignal<string[]>([]);
  const [quizListIdx, setQuizListIdx] = createSignal(0);
  const [correctQuestions, setCorrectQuestions] = createSignal<
    TQuiz["quizList"]
  >([]);
  const round = () => quizListIdx() + 1;
  const currentQuestion = createMemo(() => quiz()?.quizList[quizListIdx()]);

  createEffect(() => {
    const CHOICE_CNT = 4;
    const q = quiz();
    const currentQuizListIdx = quizListIdx();

    if (!q || q.quizList.length < CHOICE_CNT) return [];

    const choices: string[] = [q.quizList[currentQuizListIdx].answer];

    while (choices.length < CHOICE_CNT) {
      const randIdx = Math.floor(Math.random() * q.quizList.length);

      const alreadyExistInArray = choices.find(
        (c) => c === q.quizList[randIdx].answer
      );
      if (alreadyExistInArray) continue;

      choices.push(q.quizList[randIdx].answer);
    }

    setChoices(shuffleArray(choices));
  });

  const handleAnswer = (index: number) => {
    const q = quiz();
    if (!q) {
      alert("Something went wrong. Please, refresh the website.");
      return;
    }

    const userAnswer = choices()[index];
    const listIdx = quizListIdx();
    const currentQuestion = q.quizList[listIdx];

    if (currentQuestion.answer === userAnswer) {
      setCorrectQuestions((prevCorrectQuestions) =>
        prevCorrectQuestions.concat(currentQuestion)
      );
    }

    if (listIdx === q.quizList.length - 1) {
      navigate("/result", {
        state: {
          quiz: q,
          correctQuestions: correctQuestions(),
        },
      });
      return;
    }

    setQuizListIdx((prevIdx) => prevIdx + 1);
  };

  onMount(() => {
    const quiz = quizzesData.find(
      (q) => q.quizName === location.state?.quizName
    );

    if (!quiz) {
      navigate("/");
      return;
    }

    quiz.quizList = shuffleArray(quiz.quizList);
    setQuiz(quiz);
  });

  return (
    <BaseTemplate>
      <Show when={quiz() !== undefined} fallback={<div>Loading...</div>}>
        <TopProgressBar value={round()} max={quiz()?.quizList.length || 0} />
        <Question
          question={currentQuestion()?.question || ""}
          choices={choices()}
          onAnswer={handleAnswer}
        />
      </Show>
    </BaseTemplate>
  );
};

export default Quiz;
Enter fullscreen mode Exit fullscreen mode

In the onMount function, it fetches a quiz name that is passed from the Intro page. It then shuffles the quiz list and updates the quiz signal with the shuffled list. If the quiz name does not exist in the data, it redirects the user to the home page.

Whenever the quizIdx changes, indicating the current question index, the choices are updated. One choice comes from the current question, and the other choices from the other questions.

Within the createEffect logic, there is an assurance that each choice is unique, preventing duplicates from being displayed.

The Show component is used for conditional rendering. In React, you would typically use the ternary operator or custom component. However, in SolidJS, the Show component is provided out of the library, making it easier to conditionally render components based on a given condition.

2. Create src/lib/utils.ts

export const shuffleArray = <T extends unknown>(list: T[]): T[] => {
  const newList = [...list];

  for (let i = 0; i < newList.length; i++) {
    const randIdx = Math.floor(Math.random() * list.length);

    const tmp = newList[i];
    newList[i] = newList[randIdx];
    newList[randIdx] = tmp;
  }

  return newList;
};
Enter fullscreen mode Exit fullscreen mode

3. Create src/components/TopProgressBar/index.tsx

import { Component } from "solid-js";

const TopProgressBar: Component<{
  value: number;
  max: number;
}> = (props) => {
  const progressBarWidth = () =>
    Math.floor((props.value / props.max) * 100) + "%";

  const label = () => `${props.value} / ${props.max}`;

  return (
    <div class="fixed bg-sky-50 md:h-14 h-6 text-center top-0 left-0 right-0">
      <div
        class="absolute left-0 top-0 bottom-0 bg-sky-500 z-0"
        style={{
          width: progressBarWidth(),
        }}
      ></div>
      <div class="absolute left-0 top-0 bottom-0 right-0 flex justify-center items-center z-10">
        <span class="z-10 md:text-xl">{label()}</span>
      </div>
    </div>
  );
};

export default TopProgressBar;
Enter fullscreen mode Exit fullscreen mode

It receives two props, value and max, and it displays the user's progress.

4. Create src/components/Question/index.tsx

import { Component, For } from "solid-js";
import KeyEvent, { Key } from "../../components/functions/KeyEvent";

const Question: Component<{
  question: string;
  choices: string[];
  onAnswer: (index: number) => void;
}> = (props) => {
  const handleKeyEvent = (key: Key) => {
    const index = Number(key) - 1;
    if (index >= props.choices.length) return;
    props.onAnswer(index);
  };

  const keysToDetect: Key[] = ["1", "2", "3", "4"];

  return (
    <>
      <KeyEvent keys={keysToDetect} onKeyUp={handleKeyEvent} />
      <div style={{ "padding-top": "20%" }} class="h-full">
        <div class="flex justify-center h-2/5">
          <p
            class="text-center text-1xl"
            style={{
              "max-width": "80vw",
            }}
          >
            {props.question}
          </p>
        </div>
        <ul class="text-center text-lg flex flex-col items-center gap-y-6">
          <For each={props.choices}>
            {(choice, index) => (
              <li
                class="bg-white rounded drop-shadow-md cursor-pointer hover:bg-slate-50 transition"
                style={{
                  width: "300px",
                  "max-width": "80vw",
                }}
                onClick={[props.onAnswer, index()]}
              >
                {index() + 1}. {choice}
              </li>
            )}
          </For>
        </ul>
      </div>
    </>
  );
};

export default Question;
Enter fullscreen mode Exit fullscreen mode

The component displays a question and a set of choices to the user. For PC users, they can choose an answer using the keyboard Keys 1 to 4.

Regarding the onClick event handler for the li element, the syntax may look a bit unusual:

onClick={[props.onAnswer, index]}
Enter fullscreen mode Exit fullscreen mode

In SolidJS, you can pass arguments to a function using an array format.


Result Page

Finally, the last page!

1. Create src/pages/Result/index.tsx

import { A, useLocation, useNavigate } from "@solidjs/router";
import BaseTemplate from "../../components/templates/BaseTemplate";
import { Show, createEffect, createSignal, onMount } from "solid-js";
import Badge from "../../components/Badge";
import { evalScore, savePlayHistory } from "../../lib/playHistory";
import { Quiz } from "../../types/quiz";

const Result = () => {
  type LocationState = {
    quiz: Quiz;
    correctQuestions: Quiz["quizList"];
  };
  const navigate = useNavigate();
  const [locationData, setLocationData] = createSignal<LocationState>();
  const [score, setScore] = createSignal<number>();
  const location = useLocation<LocationState>();
  const badgeComp = () => {
    const s = score();
    if (s === undefined) return null;

    const grade = evalScore(s);
    if (grade === 1) {
      return <Badge color="blue">PERFECT!</Badge>;
    } else if (grade === 2) {
      return <Badge color="green">GOOD!</Badge>;
    } else {
      return <Badge color="red">PRACTICE!</Badge>;
    }
  };

  createEffect(() => {
    const { quiz } = location.state || {};
    const s = score();
    if (!quiz || s === undefined) {
      return;
    }

    savePlayHistory(quiz.quizName, s);
  });

  createEffect(() => {
    const { quiz, correctQuestions } = location.state || {};
    if (!quiz || !correctQuestions) {
      return null;
    }

    const score = correctQuestions.length / quiz.quizList.length;
    setScore(score);
  }, []);

  onMount(() => {
    const { quiz, correctQuestions } = location.state || {};
    if (!quiz || !correctQuestions) {
      navigate("/");
      return;
    }

    setLocationData({
      quiz,
      correctQuestions,
    });
  });

  return (
    <BaseTemplate>
      <Show
        when={locationData() !== undefined}
        fallback={<div>Loading...</div>}
      >
        <div class="flex items-center justify-center p-4 h-full">
          <div class="flex flex-col items-center">
            {badgeComp()}
            <h1
              class="text-5xl h-1/3 mt-4"
              style={{ "overflow-wrap": "anywhere" }}
            >
              {locationData()?.quiz.quizName}
            </h1>
            <span class="text-4xl h-1/3 mt-4">{`${
              locationData()?.correctQuestions.length
            } / ${locationData()?.quiz.quizList.length}`}</span>
            <A href="/" class="text-1xl font-bold mt-4 animate-bounce">
              GO BACK HOME
            </A>
          </div>
        </div>
      </Show>
    </BaseTemplate>
  );
};

export default Result;
Enter fullscreen mode Exit fullscreen mode

The component displays the number of questions answered correctly and the corresponding grade, which is 'Perfect', 'Good' or 'Practice'.


Wrap Up

As first, I assumed that there wouldn't be many differences between SolidJS and React, but it was completely wrong.

In SolidJS, components are only called once, which took some time for me to adjust to since I was accustomed to React. but, this behavior feels natural.

If you're already familiar with React, translating to SolidJS might be relatively easier. You can gradually learn about the framework's concepts and features.

SolidJS provides components that I would typically use javascript built-in functions or use from other libraries, such as For, Show, Index, and more, right out of the box.

Overall, I thoroughly enjoyed my learning experience with SolidJS. It was a bit short time though. If you're considering trying out a new frontend framework, I highly recommend giving it a try.

By the way, if you come across any errors in this post, please feel free to leave a comment below. If you have any ideas for improving the app, let me know or submit pull requests in the Github repository!

Happy Coding!

💖 💪 🙅 🚩
lico
SeongKuk Han

Posted on June 11, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related